This shares its root cause with the already-submitted High (REPORT_sponsor_attackModerator_corruption.md): the Pool Sponsor is, by construction of ConfidencePoolFactory.createPool's agreement.owner() == msg.sender check plus the out-of-scope AttackRegistry's _registerAgreement (attackModerator = agreementOwner), automatically the registry's attackModerator for their own agreement. That High used markCorrupted() to drain the whole pool. This is a distinct, separate impact of the same undocumented privilege: the Sponsor also controls promote()/cancelPromotion() -both onlyAttackModerator, no DAO involvement - which determines exactly when the registry reaches the terminal PRODUCTION state. That timestamp becomes T (riskWindowEnd), the upper bound in ConfidencePool's k=2 time-weighted bonus formula.
By staking early themselves and then rushing promote() the instant a later, genuinely-committed honest staker locks in (withdraw is disabled from UNDER_ATTACK onward), the Sponsor seals a small T that mathematically maximizes their own early-entry advantage in the k=2 formula - capturing a wildly disproportionate share of the bonus pool at the honest staker's expense. No principal is ever at risk - this is purely a bonus-distribution manipulation, not a drain.
src/ConfidencePoolFactory.sol:82 + lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:793 — Sponsor == agreement owner == attackModerator (same as the submitted High).
lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:300-306 (promote) - onlyAttackModerator, moves UNDER_ATTACK → PROMOTION_REQUESTED, and after PROMOTION_DELAY (3 days, line 52) the computed state becomes PRODUCTION. cancelPromotion (lines 311-317) lets the Sponsor revert back to UNDER_ATTACK and re-time a later attempt.
src/ConfidencePool.sol:727-734 (_assertDepositsAllowed) - once PROMOTION_REQUESTED is observed, stake()/contributeBonus() freeze immediately, locking in the exact staker cohort the Sponsor wants to skew against.
src/ConfidencePool.sol:293-300 (withdraw) - disabled the moment riskWindowStart != 0, so a staker who entered during UNDER_ATTACK cannot escape once they see the Sponsor rushing toward PRODUCTION.
src/ConfidencePool.sol:796-798, 819-829 (riskWindowEnd sealing) - T seals at the first observation of the terminal state. docs/DESIGN.md §7's "contested public race" defense (anyone can pokeRiskWindow() the instant a transition happens) is one-directional: it only stops T being pushed later by withholding observation. It cannot stop the Sponsor from choosing to make the transition happen early in the first place - nobody else can call promote() at all.
src/ConfidencePool.sol:696-720 (_bonusShare) - the k=2 formula share ∝ amount × (T − entryTime)². For two stakers with different entry times, shrinking T toward the later entrant's timestamp disproportionately shrinks their (T − entry)² term relative to the earlier entrant's - verified with concrete numbers below.
Neither promote, cancelPromotion, nor PROMOTION_DELAY appear anywhere in docs/DESIGN.md (confirmed via grep) - this lever is undocumented. docs/DESIGN.md §3 asserts UNDER_ATTACK entrants earn "~zero bonus," which is only true if T stays large/natural - this finding shows the Sponsor can deliberately force that assumption to fail in the opposite direction, crushing a late entrant's bonus far below what the natural, long risk window would have paid them.
Bonus-pool wealth transfer from honest, genuinely-risk-bearing stakers to the Sponsor, scaling toward ~100% capture the longer a victim waited before staking (see worst-case PoC: a staker who locked in 30 days of real at-risk capital receives only 0.82% of the bonus instead of a fair share, because the Sponsor rushes promote() the moment they commit).
No counter-move exists for the victim: deposits are frozen the instant promote() lands, withdrawal is already disabled (riskWindowStart != 0), and pokeRiskWindow() can only seal T at-or-before the current moment - never push it later to restore a fair window.
Requires nothing from the victim beyond normal participation (staking mid-window, which the protocol explicitly invites per §3) and nothing from the DAO beyond its routine approveAttack.
New scratch test, test/scratch/SponsorPromoteBonusSkewPoC.t.sol (4 tests, all passing), driven against the repo's own MockAttackRegistry (pure ConfidencePool mechanics - the real AttackRegistry's onlyAttackModerator/3-day-delay/cancelPromotion behavior is independently confirmed against the actual source at lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:300-317, and exercised upstream by that repo's own test/unit/AttackRegistryTest.t.sol::testPromote, testPromoteUnauthorized, testCancelPromotion, testPromotionDelayIs3Days).
Identical stakers, identical amounts (100e18 each), identical entry times, identical 100e18 bonus pool - the only variable is when the Sponsor lets the registry reach PRODUCTION:
| Scenario | T (riskWindowEnd) |
Sponsor bonus | Honest bonus |
|---|---|---|---|
| Baseline (natural ~90-day window) | start + 90d | 51.694e18 (51.69%) | 48.306e18 (48.31%) |
Attack (Sponsor rushes promote() at day 3 + 3-day delay) |
start + 6d | 80.0e18 (80%) | 20.0e18 (20%) |
| Worst case (victim underwrote 30 days before staking) | start + 33d | 99.18e18 (99.18%) | 0.82e18 (0.82%) |
Principal is fully returned in every case (each _claimBonus helper call asserts payout ≥ STAKE) - only the bonus split shifts.
Exclusivity confirmed: testPokeCannotCauseOrEnlargeTransition proves a normal staker's only lever, pokeRiskWindow(), can seal an already-transitioned state but never cause one - the honest staker pokes during UNDER_ATTACK and nothing happens (riskWindowEnd stays 0). Only the Sponsor's attackModerator-gated promote() can initiate the transition at all, and testPromoteFreezesStakerSet confirms deposits (and further bonus contributions) freeze the instant it's called.
Prerequisites: foundry (forge) installed. This PoC lives directly inside the target repo (test/scratch/) - no separate project needed.
1. Go to the repo root:
2. Run:
3. Actual output obtained (run independently twice, identical both times):
Attachment for triager: test/scratch/SponsorPromoteBonusSkewPoC.t.sol can be attached directly to the submission alongside this report. Full source below.
Same family of fix as the submitted High - don't let a registry-level transition initiated by an address that equals the pool's own owner() silently become the trusted T anchor for bonus distribution:
A minimum-elapsed-time floor (e.g., T cannot seal before riskWindowStart + MIN_RISK_WINDOW) is likely the simplest fix that doesn't require plumbing attackModerator lookups into the pool at all - it directly caps how early a rushed promote() can shrink T, bounding the worst-case skew regardless of who controls the timing.
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.