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

Sponsor Can Guarantee Total Bonus Forfeiture By Never Letting The Agreement Enter An Attackable State

Author Revealed upon completion

Description

  • Normal behavior: a Confidence Pool exists so that stakers can economically signal confidence in a BattleChain Safe Harbor agreement during its exposure window. Bonus contributors fund a bonus pool that is meant to be paid out, time-weighted, to stakers as compensation for taking on real attack risk while the agreement is UNDER_ATTACK / PROMOTION_REQUESTED. ConfidencePool tracks this by sealing riskWindowStart the first time it observes the registry in one of those two "active-risk" states.

  • The specific issue: ConfidencePoolFactory.createPool never checks the current registry state of the agreement it is creating a pool for, and the upstream AttackRegistry exposes a fully legitimate, owner-gated, one-time call — goToProduction() — that registers an agreement and jumps it directly to PRODUCTION, without ever passing through UNDER_ATTACK or PROMOTION_REQUESTED. Because markCorrupted/instantCorrupt (unlike goToProduction) do require the agreement to already be in an active-risk state, this "skip straight to safety" path is only reachable for the SURVIVED side of the outcome space — which is exactly the side that pays a bonus. The same actor controls the agreement (and therefore whether goToProduction() is ever used instead of the normal attack-request flow) and the pool's recoveryAddress (the destination of any swept, unclaimed bonus). This turns _bonusShare's "no observable risk → pay zero" fallback from a passive liveness edge case into a standing, zero-cost, always-available lever for the sponsor to route the entire bonus pool to themselves on every pool they create, regardless of how much real stake and bonus is collected.

/// @inheritdoc IConfidencePoolFactory
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
// @> No check whatsoever on the agreement's LIVE registry state here. A pool can be
// @> created for an agreement that has already been (or will imminently be) registered
// @> via `goToProduction()`, i.e. one that can never reach UNDER_ATTACK/PROMOTION_REQUESTED.
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt);
IConfidencePool(pool)
.initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress, // @> sponsor-controlled sweep destination for the bonus
msg.sender, // @> pool owner == whoever also controls the agreement
accounts
);
/// @dev Pays each staker their proportional share of `snapshotTotalBonus`.
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// @> No observable risk -> no bonus, for EVERY staker, unconditionally.
// @> riskWindowStart can only ever become non-zero via an *observed* active-risk state.
// @> An agreement registered through `goToProduction()` never produces one.
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
...
}
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) {
reserved += snapshotTotalBonus - claimedBonus;
}
// @> When riskWindowStart == 0, `reserved` never includes the bonus at all, so the
// @> entire snapshotTotalBonus is immediately sweepable to `recoveryAddress`.
}
...
stakeToken.safeTransfer(recoveryAddress, amount); // @> sponsor-controlled destination
}

Risk

Likelihood:

  • Occurs on every pool the sponsor creates for an agreement that is registered via goToProduction() instead of the normal registerDeploymentrequestUnderAttackapproveAttack flow — this is a first-class, documented Safe Harbor path for "protocols that don't want the attack phase," not a misuse of the registry.

  • Requires no cooperation, timing luck, or race with any other party: the sponsor decides unilaterally, at agreement-registration time, which path to take, fully independent of when stakers/bonus contributors deposit into the pool.

  • The sponsor has a direct, recurring financial incentive to take this path every time, since they also control recoveryAddress, the destination of the entire swept bonus.

Impact:

  • The bonus pool — described in the protocol's own README as "economically required for rational staker participation" — is diverted in full to the sponsor instead of the stakers who took on the pool's advertised risk.

  • Third-party bonus contributors lose their entire contribution to the sponsor with no recourse; nothing in the pool's public state before deposit distinguishes this scenario from a legitimate, still-attackable pool.

  • Stakers are not made whole on the value proposition they staked for (yield for confidence-signaling risk), even though principal is returned, undermining the core economic mechanism the contest is auditing.

Proof of Concept

This PoC uses the project's own Foundry test harness (BaseConfidencePoolTest), so it needs nothing beyond what's already in the repo — no additional mocks, no fork, no external RPC.

1. Create the test file

Create a new file at:
test/unit/Audit.SponsorBonusDrain.t.sol
and paste the following code :

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC: a sponsor whose underlying agreement never passes through an active-risk state
/// (e.g. the agreement owner calls the real registry's `goToProduction()`, a first-class,
/// permissionless-to-the-owner path for "protocols that don't want the attack phase") can
/// guarantee `riskWindowStart` never seals for their pool. Because `_bonusShare` pays zero to
/// every staker whenever `riskWindowStart == 0`, and `sweepUnclaimedBonus` routes the entire
/// unclaimed bonus to `recoveryAddress` (sponsor-controlled), the sponsor collects 100% of the
/// bonus pool while stakers who *did* take on real economic exposure recover only principal.
contract AuditSponsorBonusDrainTest is BaseConfidencePoolTest {
function testSponsorDrainsEntireBonusWhenAgreementSkipsAttackPhase() external {
// Staker takes on what they believe is real economic risk.
_stake(alice, 100 * ONE);
// A third-party bonus contributor tops up the pool - this is the "confidence" bonus the
// whole protocol exists to distribute to risk-taking stakers.
_contributeBonus(makeAddr("bonusContributor"), 50 * ONE);
// The agreement now resolves straight to PRODUCTION without ever being attackable -
// exactly what `goToProduction()` produces on the real registry.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// Fast-forward past expiry and mechanically resolve.
vm.warp(vm.getBlockTimestamp() + 32 days);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
// No risk was ever observed.
assertEq(pool.riskWindowStart(), 0);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBefore;
// Alice only ever gets her principal back - zero bonus, despite a bonus pool existing.
assertEq(alicePayout, 100 * ONE);
// The sponsor (recoveryAddress, e.g. themselves) now sweeps the *entire* bonus pool.
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 recoveryPayout = token.balanceOf(recovery) - recoveryBefore;
assertEq(recoveryPayout, 50 * ONE, "sponsor captured the full bonus pool");
}
}

2. Run it

forge test --match-contract AuditSponsorBonusDrainTest -vvvv

3. What you'll see
Ran 1 test suite in 31.58ms (13.89ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
and some verbose messages

Recommended Mitigation

The root problem is that createPool treats "the agreement was deployed by the factory" as sufficient to accept it, without asking whether the agreement can still produce a meaningful risk window. Two complementary fixes address this at different layers:

Fix 1 — gate pool creation on the agreement's live registry state (primary fix).

This stops the problem at the source: a pool should never be created for an agreement that has already left the pre-attack stage, or that can never enter one. Add a state check in createPool, reading the agreement's current state from the AttackRegistry (reachable via safeHarborRegistry.getAttackRegistry()), and reject anything that isn't still eligible to go through an active-risk phase:

+ error AgreementNotEligibleForPool();
+
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
+
+ // Reject agreements that can no longer produce an observable risk window - i.e.
+ // anything already past the active-risk stage, or (via `goToProduction()`) that never
+ // enters one at all. Only pools created while the agreement is still pre-attack or
+ // already mid-attack are economically meaningful "confidence" signals.
+ address registry = safeHarborRegistry.getAttackRegistry();
+ IAttackRegistry.ContractState state = IAttackRegistry(registry).getAgreementState(agreement);
+ if (
+ state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
+ || state == IAttackRegistry.ContractState.PRODUCTION
+ || state == IAttackRegistry.ContractState.CORRUPTED
+ ) revert AgreementNotEligibleForPool();
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt);

Why this is the right place to fix it: it's a one-time check at creation, costs a single extra external call, and closes the door before any staker or bonus contributor ever has a chance to deposit into an already-doomed pool. It doesn't change any of the pool's own accounting logic, so it can't introduce a regression in the k=2 bonus math or the resolution state machine that the rest of the test suite already exercises heavily.

Fix 2 — change the payout of forfeited bonus at the pool level (defense in depth).

Even with Fix 1, it's worth reconsidering where an unclaimed bonus goes when riskWindowStart == 0. Today it goes unconditionally to recoveryAddress — which is sponsor-controlled — so the sponsor still benefits from any edge case that produces a zero risk window (including the legitimate "nobody polled during the window" scenario DESIGN.md §5 already accepts). Routing it somewhere neutral removes the sponsor's incentive to engineer that outcome at all, rather than just making it harder to reach:

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) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
- stakeToken.safeTransfer(recoveryAddress, amount);
+ // When no risk was ever observed, the forfeited bonus should not default to the
+ // sponsor-controlled address - route it back pro-rata to bonus contributors instead
+ // (requires tracking per-contributor amounts), or to a neutral, non-sponsor-controlled
+ // destination configured at pool creation.
+ address bonusForfeitDestination = riskWindowStart == 0 ? bonusRefundPool : recoveryAddress;
+ stakeToken.safeTransfer(bonusForfeitDestination, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

best regards,
Aliyu

Support

FAQs

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

Give us feedback!