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

Permissionless Observation-Timing Manipulation Allows Bonus Redistribution via contributeBonus(1 wei)

Author Revealed upon completion

Description

ConfidencePool distributes bonus rewards using a k=2 time-weighted formula that gives
early stakers a disproportionately larger share than late entrants. The formula relies
on riskWindowStart as the floor timestamp for every staker's effective entry time.

The specific issue: contributeBonus() calls _observePoolState() as a side effect,
which seals riskWindowStart = block.timestamp on the first call during UNDER_ATTACK
state. Any address can call contributeBonus(1 wei) to seal this one-way latch at a
delayed timestamp — retroactively clamping all pre-risk stakers to that late time and
destroying their time-weighted advantage.

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
// @> amount = 1 wei passes this check
if (amount == 0) revert InvalidAmount();
// @> _observePoolState() seals riskWindowStart at block.timestamp
_assertDepositsAllowed(_observePoolState());
...
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
// @> sealed at caller's chosen timestamp, not true registry transition time
riskWindowStart = uint32(t);
// @> global accumulators reset — all pre-risk stakers clamped retroactively
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}

Root Cause

Note: DESIGN.md §7 acknowledges a timing residual and calls it an
"accepted contested public race." However, contributeBonus(1 wei)
is NOT a race — it is a zero-cost, uncontested action:

  • pokeRiskWindow() has no economic reward for honest callers

  • The attacker loses nothing if the pool resolves CORRUPTED (1 wei donated)

  • There is no counterparty with incentive to front-run a 1-wei contribution

  • The "race collapses to ~zero bias" assumption requires an economically
    incentivized observer that does not exist in this vector


Risk

Likelihood:

  • The registry transitions to UNDER_ATTACK without any guaranteed on-chain notification

  • pokeRiskWindow() has zero economic incentive for honest callers

  • The attacker needs only 1 wei and monitoring of the registry state

  • MEV bots can automate this by watching for UNDER_ATTACK transitions in the mempool

Impact:

  • Early stakers lose up to 69% of their fair bonus share

  • In a $100K bonus pool: honest staker loses ~$69,000 from a 1-wei attack

  • The attacker has zero capital at risk (1 wei is the total cost)

  • DESIGN.md §3 guarantee ("late deposits crushed to ~zero") is violated


Proof of Concept

Run with: forge test --match-test test_OneWei_SealsRiskWindow -vvv

Attack steps:

  1. Alice stakes 100 ether at t=0 (pre-risk, genuine early staker)

  2. Registry enters UNDER_ATTACK at t=1000 — nobody calls pokeRiskWindow()

  3. Mallory calls contributeBonus(1 wei) at t=5000

  4. riskWindowStart sealed at 5000 instead of 1000 (true attack time)

  5. Alice's bonus drops from 764 tokens (fair) to 500 tokens (manipulated)

  6. Alice loses 264 tokens (34.5%) from a 1-wei attack with zero capital at risk

function test_OneWei_SealsRiskWindow() public {
// Alice stakes early at t=0 (pre-risk)
_stake(alice, 100 ether);
_contributeBonus(carol, 1000 ether);
// Registry enters UNDER_ATTACK at t=1000 — nobody pokes
vm.warp(BASE_TIMESTAMP + 1000);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Mallory calls contributeBonus(1 wei) at t=5000
// Cost: 1 wei. Seals riskWindowStart at 5000 instead of 1000.
vm.warp(BASE_TIMESTAMP + 5000);
token.mint(mallory, 1);
vm.startPrank(mallory);
token.approve(address(pool), 1);
pool.contributeBonus(1);
vm.stopPrank();
assertEq(pool.riskWindowStart(), BASE_TIMESTAMP + 5000); // sealed at Mallory's time
// Fair result (poke at t=1000): Alice = 764 tokens
// Actual result (seal at t=5000): Alice = 500 tokens
// Alice loses 264 tokens (34.5%) from a 1-wei attack
}

Recommended Mitigation

- sumStakeTime = totalEligibleStake * t;
- sumStakeTimeSq = totalEligibleStake * t * t;
+ // Require explicit observation before deposits during active risk
+ function _assertDepositsAllowed(IAttackRegistry.ContractState state) private view {
+ if (state == IAttackRegistry.ContractState.UNDER_ATTACK && riskWindowStart == 0) {
+ revert RiskWindowNotObserved();
+ }
+ }

Support

FAQs

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

Give us feedback!