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

Terminal-first migration ignores local terminal finality + CORRUPTED payout reduction

Author Revealed upon completion

Description

  • Normal behavior: once the pool locally observes a terminal registry state, the local risk-window end is sealed in riskWindowEnd. Withdrawals are intended to stay closed once risk has materialized, and the design documentation states that benign upstream state rewind cannot reopen withdrawals because of the pool's one-way local latches.

  • Specific issue: withdraw() only checks riskWindowStart and the current live registry state. When the pool first observes terminal CORRUPTED before any active-risk observation, riskWindowEnd is nonzero but riskWindowStart remains zero. A trusted registry cutover or state discontinuity can then make the live registry report a pre-risk state, reopening withdrawals even though the pool already recorded terminal CORRUPTED finality. Any withdrawn stake is excluded from the later CORRUPTED snapshot and reduces the attacker/recovery payout.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// `riskWindowStart` is the pool's one-way record that risk has materialised;
// gate on it so an upstream registry rewind cannot re-open withdrawals.
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
// @> riskWindowEnd is not checked, so terminal-first observations do not block withdrawal
) {
revert WithdrawsDisabled();
}
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
// @> terminal observations are recorded only in riskWindowEnd
_markRiskWindowEnd();
}
}

Risk

Likelihood:

  • A trusted DAO / Safe Harbor registry owner performs a canonical AttackRegistry cutover or migration that exposes a fresh registry view before per-agreement terminal state has been preserved.

  • A pool has already observed terminal CORRUPTED without first observing UNDER_ATTACK or PROMOTION_REQUESTED, leaving riskWindowEnd > 0 and riskWindowStart == 0.

Impact:

  • Stakers can withdraw principal after local terminal CORRUPTED finality has been recorded.

  • The later CORRUPTED snapshot is reduced, lowering the good-faith attacker bounty or bad-faith recovery payout by the withdrawn amount.

Proof of Concept

function test_F8_terminalFirstRewindReopensWithdrawals() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "terminal-first path has no active-risk latch");
assertGt(pool.riskWindowEnd(), 0, "terminal observation is locally recorded");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.withdraw();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "withdraw reopens after terminal-first rewind");
assertEq(pool.totalEligibleStake(), 100 * ONE, "corrupted snapshot will be reduced");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(pool.snapshotTotalStaked(), 100 * ONE, "snapshot excludes withdrawn terminal-observer stake");
}

Command run:

forge test --match-path test/unit/KiraMigrationFindings.t.sol --match-test test_F8_terminalFirstRewindReopensWithdrawals -vv

Exact output:

Missing dependencies found. Installing now...
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' already exists and is not an empty directory.
fatal: clone of 'https://github.com/Cyfrin/battlechain-safe-harbor-contracts' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' failed
Failed to clone 'lib/battlechain-safe-harbor-contracts'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/forge-std' already exists and is not an empty directory.
fatal: clone of 'https://github.com/foundry-rs/forge-std' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/forge-std' failed
Failed to clone 'lib/forge-std'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts' already exists and is not an empty directory.
fatal: clone of 'https://github.com/OpenZeppelin/openzeppelin-contracts' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts' failed
Failed to clone 'lib/openzeppelin-contracts'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts-upgradeable' already exists and is not an empty directory.
fatal: clone of 'https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts-upgradeable' failed
Failed to clone 'lib/openzeppelin-contracts-upgradeable'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' already exists and is not an empty directory.
fatal: clone of 'https://github.com/Cyfrin/battlechain-safe-harbor-contracts' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' failed
Failed to clone 'lib/battlechain-safe-harbor-contracts' a second time, aborting
Warning: Your project has missing dependencies that could not be installed.
No files changed, compilation skipped
Ran 1 test for test/unit/KiraMigrationFindings.t.sol:KiraMigrationFindingsTest
[PASS] test_F8_terminalFirstRewindReopensWithdrawals() (gas: 530412)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.36ms (242.07µs CPU time)
Ran 1 test suite in 27.88ms (1.36ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
- riskWindowStart != 0
+ riskWindowStart != 0
+ || riskWindowEnd != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
}

Migration tooling should also prove per-agreement terminal-equivalent state before exposing a replacement AttackRegistry pointer.


Support

FAQs

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

Give us feedback!