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

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.)

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #7

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #7

Support

FAQs

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

Give us feedback!