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.
Impact- High Every token in the pool leaves: all staker principal plus the entire bonus, sent to an address the sponsor picked and can still change after people have deposited. Stakers have no defense once it starts, because withdraw is dead the moment riskWindowStart seals, and the mechanical resolution sets claimsStarted, which locks the moderator out of any later correction. Likelihood - Very Low The sponsor holds the role by default on every pool, and markCorrupted carries no penalty since it leaves the bond claimable, so arming this is free and unilateral once the DAO has approved the agreement into UNDER_ATTACK. What it still needs is the pool's outcome moderator staying silent for the whole term plus the 180-day grace, and one flagOutcome(SURVIVED) call anywhere in that stretch defeats it outright.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.