F-2025-0002·front-running

Exchange Rate Function Enables Direct Protocol Fee Theft

Acknowledgedliquid-stakinglststaking-poolsgithub.com/matchain/contracts
TL;DR

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.

Severity
HIGH
Impact
HIGH
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
LOW
Exploitability
HIGH
02Section · Description

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:

solidity
function exchangeRate() public returns (uint256) {
if (totalSupply() == 0) return 1e18; // Initial 1:1 ratio
uint256 currentMATBalance = stakingPool.currentStake(address(this));
// rewards case
if (currentMATBalance > lastRecordedStake) {
uint256 newRewards = currentMATBalance - lastRecordedStake;
uint256 feeAmount = (newRewards * lspFee) / 10000;
accumulatedFees += feeAmount;
lastRecordedStake = currentMATBalance;
}
// balance decrease case, sanity check
else 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:

  1. Monitoring the mempool for pending reward distribution transactions
  2. Front-running these transactions with a direct call to exchangeRate()
  3. This updates lastRecordedStake to the current balance (before rewards)
  4. When rewards are distributed immediately after, the difference between the new balance and lastRecordedStake is minimal
  5. 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.

03Section · Impact

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.

04Section · Recommendation

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
solidity
// Make the existing exchangeRate function internal or private
function _updateExchangeRate() internal returns (uint256) {
// Current implementation of exchangeRate()
// ...
}
// Use the existing view function for all rate queries
function exchangeRate() public returns (uint256) {
return getExchangeRate();
}
// Add a permissioned function to update accounting
function 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;
}
}
F-2025-0002

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx