Root + Impact
Description
For a good-faith CORRUPTED outcome, the named attacker is granted 180 days to claim the pool bounty.
The contract records the first good-faith corruption timestamp and the claim deadline using uint32:
uint32 internal _firstGoodFaithCorruptedAt;
uint32 public override corruptedClaimDeadline;
When the moderator first flags a good-faith corruption, the deadline is calculated as follows:
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt =
uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(
_firstGoodFaithCorruptedAt
+ CORRUPTED_CLAIM_WINDOW
);
The contract permits pool expiries up to:
Therefore, good-faith corruption can validly be flagged during the final 180 days before the uint32 timestamp ceiling.
When:
_firstGoodFaithCorruptedAt
> type(uint32).max - 180 days
the addition exceeds type(uint32).max. The explicit cast does not revert; it truncates the high bits and stores a small timestamp near the beginning of the Unix epoch.
For example:
flag timestamp = type(uint32).max - 100 days
intended deadline = flag timestamp + 180 days
stored deadline = intended deadline mod 2^32
The stored deadline is already far less than the current timestamp.
Consequently, immediately after the moderator flags the good-faith outcome:
function claimAttackerBounty() external {
...
if (block.timestamp > corruptedClaimDeadline) {
revert ClaimWindowExpired();
}
}
The attacker cannot claim any bounty.
At the same time, anyone can immediately execute:
function sweepUnclaimedCorrupted() external {
...
if (block.timestamp <= corruptedClaimDeadline) {
revert ClaimWindowNotExpired();
}
uint256 amount =
stakeToken.balanceOf(address(this));
stakeToken.safeTransfer(
recoveryAddress,
amount
);
}
The complete pool is transferred to recoveryAddress without providing the named good-faith attacker the intended 180-day claim period.
Risk
The named whitehat permanently loses the complete bounty entitlement.
The consequences are:
claimAttackerBounty() reverts immediately;
the full claim window is eliminated;
anyone can immediately sweep the complete pool;
all staked principal and bonus are redirected to recoveryAddress;
the moderator cannot repair the deadline through re-flagging because _firstGoodFaithCorruptedAt is intentionally never reset.
The financial impact is High, because the loss can equal the pool's complete principal and bonus balance.
The likelihood is Low, because the issue becomes reachable only during the final 180 days before type(uint32).max, approximately in 2105–2106.
Proof of Concept
Add the following test to a contract inheriting BaseConfidencePoolTest:
function test_GoodFaithDeadlineWrapsAndBountyIsImmediatelyForfeited()
external
{
uint256 maxTimestamp =
uint256(type(uint32).max);
uint256 flagTimestamp =
maxTimestamp - 100 days;
vm.warp(flagTimestamp);
pool = _deployPoolWithExpiry(
maxTimestamp - 1
);
uint256 principal = 100 * ONE;
uint256 bonus = 50 * ONE;
_stake(alice, principal);
_contributeBonus(carol, bonus);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
uint256 storedDeadline =
pool.corruptedClaimDeadline();
uint256 intendedDeadline =
flagTimestamp
+ pool.CORRUPTED_CLAIM_WINDOW();
assertGt(
intendedDeadline,
maxTimestamp,
"deadline exceeds uint32"
);
assertLt(
storedDeadline,
block.timestamp,
"stored deadline is already expired"
);
vm.prank(attacker);
vm.expectRevert(
IConfidencePool.ClaimWindowExpired.selector
);
pool.claimAttackerBounty();
uint256 recoveryBefore =
token.balanceOf(recovery);
pool.sweepUnclaimedCorrupted();
assertEq(
token.balanceOf(recovery)
- recoveryBefore,
principal + bonus
);
assertEq(
token.balanceOf(attacker),
0
);
assertEq(
token.balanceOf(address(pool)),
0
);
}
A helper can deploy the pool with a chosen expiry:
function _deployPoolWithExpiry(
uint256 expiry_
) internal returns (ConfidencePool deployed) {
deployed =
ConfidencePool(
factory.createPool(
address(agreementContract),
address(token),
expiry_,
ONE,
recovery,
_defaultScope()
)
);
}
Recommended Mitigation
Do not store the bounty timestamps as uint32.
Use uint64 or uint256:
uint64 internal _firstGoodFaithCorruptedAt;
uint64 public corruptedClaimDeadline;
Then calculate the deadline without a narrowing conversion:
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt =
uint64(block.timestamp);
}
corruptedClaimDeadline =
_firstGoodFaithCorruptedAt
+ uint64(CORRUPTED_CLAIM_WINDOW);
Using uint256 is simpler and avoids all timestamp-ceiling concerns:
uint256 internal _firstGoodFaithCorruptedAt;
uint256 public corruptedClaimDeadline;
The same timestamp-width policy should be applied consistently to: