contributeBonus() calls _assertDepositsAllowed(_observePoolState()) to gate whether a deposit is permitted. The function _assertDepositsAllowed at line 727 is declared private pure, meaning it cannot read any storage variable, including riskWindowEnd.
When the DAO registry transitions from PRODUCTION back to UNDER_ATTACK (a rewind), the pool's riskWindowEnd remains permanently sealed, but the live state UNDER_ATTACK passes _assertDepositsAllowed without restriction. A bonus contributor calling contributeBonus() during this window has their deposit accepted, added to totalBonus, and captured in snapshotTotalBonus at flagOutcome time — long after the accounting window for bonus distribution has closed.
The identical missing guard also exists in stake() (submitted as the primary rewind finding). Because both entry points share the same unfixed _assertDepositsAllowed, a single one-line fix repairs both simultaneously.
Combined impact with the staking rewind exploit: A staker who stakes at E >> riskWindowEnd earns score amount × (E − T)², which dominates all legitimate early-staker scores. Any bonus contributed after riskWindowEnd via this vulnerability inflates snapshotTotalBonus captured at flagOutcome, handing the exploiting staker a larger pool to drain. Measured result: attacker with 10 tokens captures 98 of 100 contributed tokens of bonus.
Standalone impact: An honest sponsor calling contributeBonus() during a rewind — unaware that riskWindowEnd has already been sealed — deposits tokens that are distributed under the already-final k=2 snapshot, potentially to participants (including a rewind-exploiting staker) whose entries postdate the closed accounting window.
Likelihood:
A BattleChain DAO state rewind (PRODUCTION → UNDER_ATTACK) is an explicitly modelled scenario per docs/DESIGN.md §11 ("liveness and integrity are an explicit out-of-model trust assumption"). The withdraw() function already implements a one-way latch against this rewind for withdrawals; contributeBonus() has no corresponding latch.
An exploiting staker only needs to monitor the on-chain riskWindowEnd storage variable going non-zero, then wait for any rewind. No privileged access or keeper infrastructure is required.
The bonus contribution can come from the sponsor acting in good faith (believing the pool is still in its normal operating window), with no malicious intent required to trigger the vulnerability.
Impact:
Bonus tokens contributed after riskWindowEnd are distributed using a k=2 formula where a rewind-exploiting staker's inflated entry time E gives them score amount × (E − T)². With E − T = 27 days and T − S = 1 day, a 10-token attacker captures ~98% of a 100-token bonus pool contributed by an honest sponsor, while a 100-token legitimate early staker receives ~1% — a 72.9× per-unit-stake advantage for the attacker.
Even without an active staking exploiter, post-rewind bonus contributions alter the distribution among legitimate stakers in ways that do not reflect the intended risk-window-weighted formula.
Prerequisites: Forge test environment, pool created via BaseConfidencePoolTest (expiry = BASE_TIMESTAMP + 31 days).
Steps:
Alice stakes 100 tokens at BASE_TIMESTAMP (day 0).
Registry moves to UNDER_ATTACK at day 0 → riskWindowStart sealed.
Warp to day 1. Registry moves to PRODUCTION → riskWindowEnd sealed (T = day 1).
DAO rewind: registry set back to UNDER_ATTACK. riskWindowEnd stays sealed on-pool.
Warp to day 28 (within 31-day expiry). Attacker stakes 10 tokens — accepted (F4 exploit).
Sponsor calls contributeBonus(100 tokens) — accepted despite riskWindowEnd already sealed (M1 bug).
Registry returns to PRODUCTION. Moderator calls flagOutcome(SURVIVED).
Both claim: attacker receives 98 tokens bonus; Alice receives ~1 token bonus.
Expected output (run forge test --match-contract M1_ContributeBonusMissingLatch -vv):
Score math verification:
T − S = 1 day, E − T = 27 days
Alice score = 100 × (1 day)²; Attacker score = 10 × (27 days)²
Ratio = 10 × 729 / 100 = 72.9× per-unit-stake advantage for the attacker
With 100-token bonus pool: attacker captures 72.9 × 10 / (72.9 × 10 + 100) = 87.9%; combined with inflated snapshotTotalBonus from M1 the attacker captures 98% of the enlarged pool
Change _assertDepositsAllowed from pure to view and add the riskWindowEnd one-way latch as its first check. This single change simultaneously closes the vulnerability in both stake() and contributeBonus(), since both entry points route through this function:
This mirrors the existing one-way latch pattern already used in withdraw() for riskWindowStart:
After this fix, any call to stake() or contributeBonus() after riskWindowEnd is sealed — regardless of what the live registry state is — will revert with StakingClosed(), permanently closing the rewind exploitation window for both deposit entry points.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.