Root + Impact
Description
When a ConfidencePool reaches its configured expiry, a non-terminal active-risk registry state
such as UNDER_ATTACK or PROMOTION_REQUESTED should resolve the pool as EXPIRED. This is the
intended "the agreement survived this pool term" path, and stakers should be able to claim their
principal plus the sponsor-funded bonus.
The implementation does not bind the expiry settlement decision to the registry state at the pool's
expiry boundary. Instead, the first post-expiry claimExpired() call reads the registry's current
state through _observePoolState(). A pool that was active-risk/non-terminal when its term ended
can therefore be finalized later as CORRUPTED after the registry changes post-expiry and the
moderator grace period elapses.
The root cause is in ConfidencePool.claimExpired(): the settlement outcome is chosen from the
call-time registry state, not from an expiry-time checkpoint.
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) {
@> IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
@> 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;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
@> outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
}
}
The impact is incorrect fund settlement after the pool term has already ended. Stakers who should
receive the EXPIRED payout lose that claim path, including both their principal and their share of
the sponsor bonus. The same already-expired pool is instead finalized as bad-faith CORRUPTED, and
the full pool balance is moved into the corrupted recovery path.
The amount at risk is the pool's token balance at resolution time:
snapshotTotalStaked + snapshotTotalBonus. In the PoC this is 100 * ONE of staker principal plus
50 * ONE of sponsor bonus, and the raw token balance delta confirms that 150 * ONE is swept away
from the normal staker payout path:
raw_recovery_delta_after_claimCorrupted 150000000000000000000
Risk
Likelihood:
A pool can remain unresolved after expiry while its registry state later advances to CORRUPTED; claimExpired() has no expiry-time state checkpoint, so the first post-grace caller resolves against the later state.
claimExpired() is public. The triggering caller does not need to be the pool owner, moderator, sponsor, staker, or attacker.
The required states are normal protocol lifecycle states: active-risk at expiry, followed by a later terminal CORRUPTED state.
Impact:
Stakers can be denied the intended EXPIRED return of principal and bonus.
Sponsor-funded bonus can be redirected away from the confidence-pool reward mechanism.
The pool can be settled based on a registry transition that happened after the pool's term ended.
The full staked principal plus bonus balance of the affected pool can be swept through claimCorrupted() to recoveryAddress.
I rate this as Medium because it can redirect real pool funds and is reachable through a public
function, but it depends on a specific timing sequence: active-risk at expiry, delayed expiry
resolution, a later registry transition to CORRUPTED, and waiting through the moderator grace
period.
Proof of Concept
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {console2} from "forge-std/console2.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PostExpiryCorruptionPoCTest is BaseConfidencePoolTest {
function test_delayedExpiryCanResolvePostTermCorruption() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 expiry = pool.expiry();
vm.warp(expiry);
uint256 rawRegistryStateAtExpiry = uint256(attackRegistry.getAgreementState(agreement));
uint256 rawPoolOutcomeAtExpiry = uint256(pool.outcome());
console2.log("BC-CAN-003 raw contract reads at expiry");
console2.log("raw_registry_state_at_expiry", rawRegistryStateAtExpiry);
console2.log("raw_pool_outcome_at_expiry", rawPoolOutcomeAtExpiry);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
uint256 rawRegistryStateAfterExpiryBeforeClaim =
uint256(attackRegistry.getAgreementState(agreement));
console2.log("BC-CAN-003 raw contract reads after post-expiry registry transition");
console2.log("raw_registry_state_after_expiry_before_claim", rawRegistryStateAfterExpiryBeforeClaim);
pool.claimExpired();
uint256 rawPoolOutcomeAfterClaimExpired = uint256(pool.outcome());
console2.log("BC-CAN-003 raw contract reads after claimExpired");
console2.log("raw_pool_outcome_after_claimExpired", rawPoolOutcomeAfterClaimExpired);
assertEq(rawPoolOutcomeAfterClaimExpired, uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted(), "auto-CORRUPTED finality locked");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
uint256 recoveryDelta = token.balanceOf(recovery) - recoveryBefore;
console2.log("BC-CAN-003 raw token balance delta after claimCorrupted");
console2.log("raw_recovery_delta_after_claimCorrupted", recoveryDelta);
assertEq(recoveryDelta, 150 * ONE, "full pool swept after delayed call");
}
}
```
Run command:
forge test --match-path test/validation/PostExpiryCorruptionPoC.t.sol --match-test test_delayedExpiryCanResolvePostTermCorruption -vvv --summary
[PASS] test_delayedExpiryCanResolvePostTermCorruption() (gas: 533390)
Logs:
raw_registry_state_at_expiry 3
raw_pool_outcome_at_expiry 0
raw_registry_state_after_expiry_before_claim 6
raw_pool_outcome_after_claimExpired 2
raw_recovery_delta_after_claimCorrupted 150000000000000000000
Enum mapping:
- IAttackRegistry.ContractState.UNDER_ATTACK = 3
- IAttackRegistry.ContractState.CORRUPTED = 6
- PoolStates.Outcome.UNRESOLVED = 0
- PoolStates.Outcome.CORRUPTED = 2
```
Recommended Mitigation
@@
uint32 public override riskWindowEnd;
+ uint64 public firstTerminalObservedAt;
@@
function _markRiskWindowEnd() internal {
- uint256 t = block.timestamp;
+ uint256 observedAt = block.timestamp;
+ if (firstTerminalObservedAt == 0) {
+ firstTerminalObservedAt = uint64(observedAt);
+ }
+
+ uint256 t = observedAt;
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}
@@
function claimExpired() external nonReentrant {
...
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ bool corruptedDuringPoolTerm =
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && firstTerminalObservedAt != 0
+ && firstTerminalObservedAt <= expiry;
+
+ if (corruptedDuringPoolTerm) {
...
} else if (state == IAttackRegistry.ContractState.PRODUCTION) {
...
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
...
}
}