Exchange Rate Function Enables Direct Protocol Fee Theft
exchangeRate() is a public function that simultaneously reads the current rate and writes lastRecordedStake / accumulatedFees. Anyone can front-run reward distribution with a no-cost call to consume the reward delta and starve the protocol's fee accounting.
Description
The exchangeRate() function in the LiquidStakingPool contract combines both reading and state-modifying operations in a single public function. This critical design flaw enables a direct front-running attack that can systematically deprive the protocol of fee revenue with minimal effort and no capital requirement.
The vulnerable code is in the exchangeRate() function:
function exchangeRate() public returns (uint256) {if (totalSupply() == 0) return 1e18; // Initial 1:1 ratiouint256 currentMATBalance = stakingPool.currentStake(address(this));// rewards caseif (currentMATBalance > lastRecordedStake) {uint256 newRewards = currentMATBalance - lastRecordedStake;uint256 feeAmount = (newRewards * lspFee) / 10000;accumulatedFees += feeAmount;lastRecordedStake = currentMATBalance;}// balance decrease case, sanity checkelse if (currentMATBalance < lastRecordedStake) {lastRecordedStake = currentMATBalance;}uint256 effectiveBalance = currentMATBalance > accumulatedFees ?currentMATBalance - accumulatedFees : 0;uint256 calculatedRate = (effectiveBalance * 1e18) / totalSupply();return calculatedRate < 1e18 ? 1e18 : calculatedRate; // never falls below 1:1}
The core vulnerability stems from the dual nature of the exchangeRate() function. While it appears to be a simple getter function that returns the current exchange rate between stMAT and MAT, it actually performs significant state changes by updating the lastRecordedStake variable and calculating protocol fees. Since this function is public, anyone can call it directly without any restrictions or capital requirements.
An attacker can exploit this by:
- Monitoring the mempool for pending reward distribution transactions
- Front-running these transactions with a direct call to
exchangeRate() - This updates
lastRecordedStaketo the current balance (before rewards) - When rewards are distributed immediately after, the difference between the new balance and
lastRecordedStakeis minimal - The protocol calculates fees on this small difference instead of the full reward amount
What makes this attack particularly concerning is its simplicity and low barrier to entry. The attacker doesn't need to stake any tokens, doesn't need any special permissions, and only pays for gas costs. With a simple automated bot monitoring the mempool, this attack could be executed continuously against every reward distribution.
Impact
The long-term impact of this vulnerability is severe. By systematically intercepting fee collection, an attacker effectively cuts off the protocol's primary revenue stream.
A concrete example: with lastRecordedStake = 1,000,000 MAT, actual current stake of 1,100,000 MAT (100,000 MAT in uncounted rewards), and lspFee = 10%, an attacker front-running with a exchangeRate() call updates lastRecordedStake to the pre-reward balance. When the legitimate reward distribution adds another 50,000 MAT, the next call sees newRewards = 50,000 instead of 150,000, and feeAmount = 5,000 MAT instead of 15,000. Protocol loses 10,000 MAT in fee revenue per attack cycle.
Recommendation
Separate Read and Write Operations:
- Create a view-only function for getting the exchange rate (the contract already has
getExchangeRate()but it's not used consistently) - Create a separate permissioned function for updating fee accounting
- Ensure all user-facing functions use the view function, not the state-modifying one
// Make the existing exchangeRate function internal or privatefunction _updateExchangeRate() internal returns (uint256) {// Current implementation of exchangeRate()// ...}// Use the existing view function for all rate queriesfunction exchangeRate() public returns (uint256) {return getExchangeRate();}// Add a permissioned function to update accountingfunction updateFeeAccounting() public onlyRole(FEE_MANAGER_ROLE) {uint256 currentMATBalance = stakingPool.currentStake(address(this));if (currentMATBalance > lastRecordedStake) {uint256 newRewards = currentMATBalance - lastRecordedStake;uint256 feeAmount = (newRewards * lspFee) / 10000;accumulatedFees += feeAmount;lastRecordedStake = currentMATBalance;} else if (currentMATBalance < lastRecordedStake) {lastRecordedStake = currentMATBalance;}}

