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

Sponsor (== BattleChain attackModerator) can starve at-risk stakers of their entire bonus by keeping the pool passive through the active-risk window

Author Revealed upon completion

Root + Impact

Description

Normal behavior: the bonus pool exists to compensate stakers for genuinely holding capital at risk while the underlying BattleChain agreement is attackable (UNDER_ATTACK/PROMOTION_REQUESTED). The k=2 time-weighted formula is supposed to pay stakers in proportion to how long they were exposed to that real risk before the term resolved.

The issue: riskWindowStart is not derived from the registry's actual state-transition history — it is set lazily, the first time any pool function happens to be called while _getAgreementState() currently reads an active-risk state:


// Root cause in the codebase with @> marks to highlight the relevant
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart();
@> }
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> // No observable risk → no bonus (see contract natspec).
@> if (riskWindowStart == 0) return 0;
...
}
function sweepUnclaimedBonus() external nonReentrant {
...
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus;
@> }
}
...
@> stakeToken.safeTransfer(recoveryAddress, amount); // sweeps the FULL untouched bonus
}

If the registry genuinely passes through UNDER_ATTACK/PROMOTION_REQUESTED — real risk, for real — but nobody happens to call a pool function (stake, pokeRiskWindow(), anything) during that window before it resolves terminal, riskWindowStart never seals. Every staker's bonus share is then forced to zero, and the whole bonus becomes sweepable to recoveryAddress.

Critically, ConfidencePoolFactory.createPool requires the pool creator to be IAgreement(agreement).owner():

"

if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();

"

and BattleChain's AttackRegistry._registerAgreement assigns that same owner as the agreement's attackModerator:

"s_agreementInfo[agreementAddress] = AgreementInfo({
attackModerator: agreementOwner,
...
});"


attackModerator alone can call promote(), which advances UNDER_ATTACK → PROMOTION_REQUESTED. So the pool sponsor is structurally the same address that controls the pace of the underlying registry's risk-state transitions, and has direct financial motive (reclaiming the bonus they funded) to do nothing on their own pool while nudging the agreement through the active-risk window as fast as possible

Risk

Likelihood:

  • The sponsor is, by construction of createPool's ownership check plus the registry's attackModerator assignment, the same entity empowered to call promote() on the underlying agreement.

  • pokeRiskWindow() is opt-in, permissionless, and costs gas; most stakers in a given pool are passively waiting for the final outcome rather than actively monitoring and poking during the exact active-risk interval.

  • A pool with light day-to-day attention (the common case — this is a "confidence" instrument, not an actively-managed position) goes through its entire UNDER_ATTACK/PROMOTION_REQUESTED window without a single incidental pool interaction whenever nobody stakes, withdraws, or contributes bonus during that specific stretch.

Impact:

  • 100% of the contributed bonus is redirected away from stakers who genuinely held capital at risk for the full covered term, to recoveryAddress — an address the sponsor controls.

  • Stakers recover only principal, receiving zero compensation for real risk taken, which defeats the stated economic purpose of the confidence pool (the bonus is the entire incentive to stake in the first place).

  • The sponsor bears no cost: they simply reclaim funds they already intended to distribute, with no on-chain evidence distinguishing "genuinely no risk occurred" from "risk occurred but was never observed."

Proof of Concept

// forge test --match-test test_PoC_RealRiskNeverObserved_FullBonusStolenFromHonestStakers -vv
function test_PoC_RealRiskNeverObserved_FullBonusStolenFromHonestStakers() public {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(moderator, 50 * ONE); // sponsor-funded confidence bonus
// Genuine, real, on-chain risk for the entire remainder of the term.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(block.timestamp + 20 days); // nobody interacts with the pool during this window
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0); // window never sealed despite 20 real days of risk
vm.prank(alice); pool.claimSurvived();
vm.prank(bob); pool.claimSurvived();
assertEq(token.balanceOf(alice), 100 * ONE); // principal only, zero bonus
assertEq(token.balanceOf(bob), 100 * ONE);
uint256 before = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - before, 50 * ONE); // full bonus diverted
}

Recommended Mitigation

- remove this code
+ add this
Remove the free option to stay passive: pay the caller who first successfully seals the risk window a small keeper incentive out of the bonus pool, so it is never in anyone's economic interest — including a griefing sponsor's counterparties — to simply wait it out
+ uint256 public constant RISK_WINDOW_POKE_REWARD_BPS = 25; // 0.25% of totalBonus, capped
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
- _observePoolState();
+ bool alreadySealed = riskWindowStart != 0 || riskWindowEnd != 0;
+ _observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ if (!alreadySealed && totalBonus > 0) {
+ uint256 reward = (totalBonus * RISK_WINDOW_POKE_REWARD_BPS) / 10_000;
+ if (reward > 0) {
+ totalBonus -= reward;
+ stakeToken.safeTransfer(msg.sender, reward);
+ }
+ }
}

Support

FAQs

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

Give us feedback!