Normal behavior: a Confidence Pool exists so that stakers can economically signal confidence in a BattleChain Safe Harbor agreement during its exposure window. Bonus contributors fund a bonus pool that is meant to be paid out, time-weighted, to stakers as compensation for taking on real attack risk while the agreement is UNDER_ATTACK / PROMOTION_REQUESTED. ConfidencePool tracks this by sealing riskWindowStart the first time it observes the registry in one of those two "active-risk" states.
The specific issue: ConfidencePoolFactory.createPool never checks the current registry state of the agreement it is creating a pool for, and the upstream AttackRegistry exposes a fully legitimate, owner-gated, one-time call — goToProduction() — that registers an agreement and jumps it directly to PRODUCTION, without ever passing through UNDER_ATTACK or PROMOTION_REQUESTED. Because markCorrupted/instantCorrupt (unlike goToProduction) do require the agreement to already be in an active-risk state, this "skip straight to safety" path is only reachable for the SURVIVED side of the outcome space — which is exactly the side that pays a bonus. The same actor controls the agreement (and therefore whether goToProduction() is ever used instead of the normal attack-request flow) and the pool's recoveryAddress (the destination of any swept, unclaimed bonus). This turns _bonusShare's "no observable risk → pay zero" fallback from a passive liveness edge case into a standing, zero-cost, always-available lever for the sponsor to route the entire bonus pool to themselves on every pool they create, regardless of how much real stake and bonus is collected.
Likelihood:
Occurs on every pool the sponsor creates for an agreement that is registered via goToProduction() instead of the normal registerDeployment → requestUnderAttack → approveAttack flow — this is a first-class, documented Safe Harbor path for "protocols that don't want the attack phase," not a misuse of the registry.
Requires no cooperation, timing luck, or race with any other party: the sponsor decides unilaterally, at agreement-registration time, which path to take, fully independent of when stakers/bonus contributors deposit into the pool.
The sponsor has a direct, recurring financial incentive to take this path every time, since they also control recoveryAddress, the destination of the entire swept bonus.
Impact:
The bonus pool — described in the protocol's own README as "economically required for rational staker participation" — is diverted in full to the sponsor instead of the stakers who took on the pool's advertised risk.
Third-party bonus contributors lose their entire contribution to the sponsor with no recourse; nothing in the pool's public state before deposit distinguishes this scenario from a legitimate, still-attackable pool.
Stakers are not made whole on the value proposition they staked for (yield for confidence-signaling risk), even though principal is returned, undermining the core economic mechanism the contest is auditing.
This PoC uses the project's own Foundry test harness (BaseConfidencePoolTest), so it needs nothing beyond what's already in the repo — no additional mocks, no fork, no external RPC.
1. Create the test file
Create a new file at:
test/unit/Audit.SponsorBonusDrain.t.sol
and paste the following code :
2. Run it
3. What you'll see
Ran 1 test suite in 31.58ms (13.89ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
and some verbose messages
The root problem is that createPool treats "the agreement was deployed by the factory" as sufficient to accept it, without asking whether the agreement can still produce a meaningful risk window. Two complementary fixes address this at different layers:
Fix 1 — gate pool creation on the agreement's live registry state (primary fix).
This stops the problem at the source: a pool should never be created for an agreement that has already left the pre-attack stage, or that can never enter one. Add a state check in createPool, reading the agreement's current state from the AttackRegistry (reachable via safeHarborRegistry.getAttackRegistry()), and reject anything that isn't still eligible to go through an active-risk phase:
Why this is the right place to fix it: it's a one-time check at creation, costs a single extra external call, and closes the door before any staker or bonus contributor ever has a chance to deposit into an already-doomed pool. It doesn't change any of the pool's own accounting logic, so it can't introduce a regression in the k=2 bonus math or the resolution state machine that the rest of the test suite already exercises heavily.
Fix 2 — change the payout of forfeited bonus at the pool level (defense in depth).
Even with Fix 1, it's worth reconsidering where an unclaimed bonus goes when riskWindowStart == 0. Today it goes unconditionally to recoveryAddress — which is sponsor-controlled — so the sponsor still benefits from any edge case that produces a zero risk window (including the legitimate "nobody polled during the window" scenario DESIGN.md §5 already accepts). Routing it somewhere neutral removes the sponsor's incentive to engineer that outcome at all, rather than just making it harder to reach:
best regards,
Aliyu
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.