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

k=2 time-weighted bonus silently degrades to amount-weighting on a zero-duration risk window, letting a late large staker capture the bonus earned by time-at-risk

Author Revealed upon completion

Description

The bonus pool is meant to be split by a k=2 time-weighted score (amount × (T − entry)²), which crushes
late entrants to a near-zero share so that stakers who actually bore at-risk time earn the reward.

When the observed risk window has zero effective duration, globalScore evaluates to 0 and
_bonusShare falls back to a pure amount-weighted split. In that fallback a staker who entered late
with a large principal receives a share proportional to stake instead of the ~0 the time-weighting
intends, diluting the bonus owed to the stakers who bore the risk.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// ... userScore / globalScore = T²·Σa − 2T·Σ(a·t) + Σ(a·t²) ...
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
@> return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked); // amount-weighted fallback
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

globalScore == 0 is reached whenever every deposit's effective (T − entry) collapses to zero:
riskWindowStart == riskWindowEnd (window opened and closed the same block), a window first observed
at/after expiry, or a riskWindowStart > riskWindowEnd inversion after a registry re-attack (which
underflow-guards every per-entry term to 0).

Risk

Likelihood:

  • Occurs when the BattleChain registry drives the pool through a zero-duration risk window — an
    UNDER_ATTACK → PRODUCTION transition observed in the same block, or a PRODUCTION → UNDER_ATTACK
    re-attack that seals riskWindowStart after riskWindowEnd.

  • Occurs only while no honest staker has sealed a positive-duration window via pokeRiskWindow(); in a
    normal window the k=2 crush already defeats a late entrant.

Impact:

  • The bonus is redistributed toward raw principal, so a late whale can capture ~99% of the bonus a
    risk-bearing early staker earned (measured in PoC: 299.7 / 300).

  • Bounded: principal is never lost and the pool stays solvent (conservation holds); the effect is
    confined to the bonus pool and is registry-gated, not attacker-forceable.

Proof of Concept

test/audit/Escalation.t.sol — a whale with 1000× the honest staker's principal captures the bonus
under the fallback, yet no principal is lost and the bonus never over-distributes:

function testEsc_fallbackIsRedistributionNotLoss() external {
_stake(alice, 100 * ONE);
_stake(bob, 100_000 * ONE); // late whale
_contributeBonus(carol, 300 * ONE);
// DAO drives an instant window -> globalScore==0 -> amount-weighted fallback
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBonus = _claimBonus(alice, 100 * ONE); // ~0.30 ONE
uint256 bobBonus = _claimBonus(bob, 100_000 * ONE); // ~299.70 ONE
assertLe(aliceBonus + bobBonus, 300 * ONE); // conservation holds
// testEsc_pokeProtectsEarlyStakerFromLateWhale: an early poker KEEPS the bonus in a normal window.
}

Recommended Mitigation

Make the degenerate case explicit rather than silently amount-weighting, and forbid the inverted window:

+ // A window sealed with start > end is a registry-inversion artefact, not a real risk interval.
+ require(riskWindowStart <= riskWindowEnd || outcomeFlaggedAt == expiry, "inverted risk window");
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
- return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
+ // Document: with no measured time spread the split is amount-weighted by design (DESIGN §7).
+ return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}

(Alternatively, keep the fallback but document it as an accepted redistribution in the payout natspec.)

Support

FAQs

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

Give us feedback!