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

uint32 deadline truncation immediately forfeits the good-faith attacker bounty near the timestamp ceiling

Author Revealed upon completion

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);
}
// @> The uint256 result is truncated to uint32.
corruptedClaimDeadline = uint32(
_firstGoodFaithCorruptedAt
+ CORRUPTED_CLAIM_WINDOW
);

The contract permits pool expiries up to:

type(uint32).max

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 {
...
// @> Immediately true because the stored deadline wrapped.
if (block.timestamp > corruptedClaimDeadline) {
revert ClaimWindowExpired();
}
}

The attacker cannot claim any bounty.

At the same time, anyone can immediately execute:

function sweepUnclaimedCorrupted() external {
...
// @> The wrapped deadline appears to have expired decades ago.
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);
// Enter the final 180-day region before the uint32 ceiling.
uint256 flagTimestamp =
maxTimestamp - 100 days;
vm.warp(flagTimestamp);
// Deploy a pool whose expiry still satisfies the minimum lead
// requirement while remaining inside the uint32 range.
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();
// The intended deadline is 80 days beyond the uint32 ceiling.
uint256 intendedDeadline =
flagTimestamp
+ pool.CORRUPTED_CLAIM_WINDOW();
assertGt(
intendedDeadline,
maxTimestamp,
"deadline exceeds uint32"
);
// Explicit uint32 conversion truncated the deadline.
assertLt(
storedDeadline,
block.timestamp,
"stored deadline is already expired"
);
// The named attacker is denied the complete bounty immediately.
vm.prank(attacker);
vm.expectRevert(
IConfidencePool.ClaimWindowExpired.selector
);
pool.claimAttackerBounty();
uint256 recoveryBefore =
token.balanceOf(recovery);
// Anyone can immediately sweep the complete pool.
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:

  • expiry;

  • riskWindowStart;

  • riskWindowEnd;

  • outcomeFlaggedAt;

  • _firstGoodFaithCorruptedAt;

  • corruptedClaimDeadline.

Support

FAQs

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

Give us feedback!