FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: high

Pool Sponsor can guarantee riskWindowStart never opens, zeroing all staker/donor bonus to their own recoveryAddress

Author Revealed upon completion

Root + Impact

Description

  • _bonusShare() pays stakers a bonus based on time spent in the agreement's active-risk window (UNDER_ATTACK/PROMOTION_REQUESTED), paying zero only when that window was never organically observed.

  • createPool requires the pool creator to be the Agreement owner, so the Sponsor always controls the Agreement. The owner can reach PRODUCTION without ever entering an active-risk state (via the real AttackRegistry's permissionless goToProduction(), or simply never calling requestUnderAttack), permanently zeroing riskWindowStart and sweeping 100% of the bonus -- including third-party donations -- to their own recoveryAddress.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
...
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0;
...
}



Risk

Likelihood:

  • Reason 1: The Sponsor always controls the Agreement whose state gates the bonus (createPool enforces owner == creator).

  • Reason 2:Stakers get principal-only on a resolution that looks like an honest SURVIVED/EXPIRED, with no way to detect the skipped attack phase on-chain.

Impact:

  • Impact 1: 100% of the bonus, including third-party donations, goes to the Sponsor's recoveryAddress instead of the stakers..

  • Impact 2: Stakers get principal-only on an outcome that looks like a clean SURVIVED/EXPIRED resolution, with no on-chain way to tell "genuinely tested" from "sponsor skipped the attack phase."

Proof of Concept

Verified at three escalating levels of rigor:
1. Local mocks: sponsor forces PRODUCTION without ever entering UNDER_ATTACK; two stakers + a third-party donor deposit; moderator honestly flags SURVIVED; both stakers get 0 bonus; sponsor sweeps 100% of the donation.
2. Fork simulation against the live BattleChain testnet registry (chainId 627): fresh Agreement registered via the real AgreementFactory, confirmed via live staticcall that AttackRegistry.getAgreementState() stayed NOT_DEPLOYED throughout.
3. A REAL signed broadcast (forge script --broadcast) against the live chain -- not a simulation. Deployed a scope contract via the real, permissionless BattleChainDeployer (auto-authorizing the caller as owner), registered a fresh real Agreement naming it in scope, satisfied the 7-day MIN_COMMITMENT gate via extendCommitmentWindow(), staked + contributed bonus into a real ConfidencePool, then called the REAL AttackRegistry.goToProduction() (skipping UNDER_ATTACK/PROMOTION_REQUESTED entirely), flagged SURVIVED, claimed, and swept:
- goToProduction() tx: 0xc6d8476bb95ce0026bff66b466d335767500b4d6ed4d35bcac79563ff1ddc18e (gasUsed 273002, status success)
- flagOutcome(SURVIVED) tx: 0x8f6e88ba21e431ff56ddfa01ed4fcbe2130d3aa9352d9a8a8a8abbc378454411 (status success)
- claimSurvived() tx: 0x17c119911b0e0b5f4572da23aa7529252fea3a95b14633ab9a88578cd5fdfbe6 (gasUsed 399268, status success) -- staker received exactly 10 tokens = principal only, 0 bonus
- sweepUnclaimedBonus() tx: 0x34ce030fc0c84ffa8f54ea6968a9f67681fccd0148a33c40073e2f763519483b (gasUsed 209800, status success) -- sponsor's recoveryAddress received the full 5-token third-party bonus donation
Independently re-verified by querying the sponsor's live token balance directly from the L2 RPC node post-sweep: exactly 15 tokens (10 principal + 5 swept bonus), confirming the effect on-chain, not just the receipt status.
Live Agreement: 0xb9d20a9CC6843FfFf222Cb9F53403c7F5D725274
Live ConfidencePool: 0xFBD65230c49e9ef81a6f5a8EE56f4dBDBcB82c89
Publicly verifiable at explorer.testnet.battlechain.com.
function test_PoC_sponsorSkipsAttackPhase_stealsEntireBonus() external {
_stake(alice, 1_000 * ONE);
_stake(bob, 1_000 * ONE);
address donor = makeAddr("genuineBonusDonor");
_contributeBonus(donor, 500 * ONE);
// Reproduced against the REAL registry with a real signed broadcast -- see PoC section
// above for tx hashes (goToProduction / flagOutcome / claimSurvived / sweepUnclaimedBonus).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
vm.prank(alice); pool.claimSurvived(); // bonus == 0
vm.prank(bob); pool.claimSurvived(); // bonus == 0
pool.sweepUnclaimedBonus(); // recoveryAddress gains 500 * ONE (100% of the donation)
}

Recommended Mitigation

The Sponsor shouldn't profit from guaranteeing no risk was observed -- refund unclaimed bonus to its original contributors instead of sweeping it to the Sponsor's recoveryAddress.
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
- if (riskWindowStart == 0) return 0;
+ // "No observable risk" should not silently enrich the Sponsor-controlled recoveryAddress.
+ // Route bonus disposition through a path that doesn't reward the Sponsor for guaranteeing
+ // risk was never observed -- e.g. make it refundable to the original contributeBonus()
+ // callers instead of sweepable to recoveryAddress, or gate pool creation / bonus
+ // acceptance on the agreement already being at or past ATTACK_REQUESTED so the Sponsor has
+ // already committed to the stress-test phase before third parties can be lured into
+ // funding the bonus.
...
}

Support

FAQs

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

Give us feedback!