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;
...
}
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);
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();
vm.prank(bob); pool.claimSurvived();
pool.sweepUnclaimedBonus();
}
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.
...
}