MCN Labs reportedly suffered a loss of approximately $92.6K due to an accounting flaw in its LPBonus reward contract.
The vulnerability came from a subtle inconsistency:
The contract used one MSN reserve value when creating the FIST reward index, but another reserve value when calculating how much reward a user could claim.
By manipulating the reserve between these two operations, the attacker was able to receive more FIST than the amount that should have been distributed.
0x52272524a22f941f5489c1233732797314bb054b0xb6fff29dd2b5423a159e50877fc4af7a54e76f7aAccording to the reported attack data, the attacker reduced the MSN reserve to approximately:
89.327788899759978606 MSN
during reward accrual.
Before triggering LP removal, the reserve was increased to approximately:
491.113095162589329323 MSN
This difference allowed one LP holder to claim approximately:
1,442,165.713011 FIST
even though the relevant reward increment was funded by only approximately:
940,041.612768 FIST
The main issue can be found in the way oneshareFIST is updated.
Inside AddFistFee, the contract swaps MSN into FIST and calculates the newly received FIST:
uint256 balanebefore = IBEP20(fist).balanceOf(address(this));
router.swapExactTokensForTokensSupportingFeeOnTransferTokens(
amountofmsn,
0,
path,
address(this),
1e40
);
uint256 balanceafter = IBEP20(fist).balanceOf(address(this));
uint256 amount = balanceafter.sub2(balanebefore);
It then updates the global reward index:
oneshareFIST += amount.mul(1e18).div(reservethis);
In simplified form:
Reward Index Increase = FIST Reward / MSN Reserve
This means that when the MSN reserve becomes smaller, the increase in oneshareFIST becomes larger.
The problem appears when rewards are later calculated.
CalcPendingUser calculates a user’s LP weight using the current reserve:
uint256 newLpbalance = IBEP20(lpaddress).balanceOf(user);
uint256 totalsupplylp = IBEP20(lpaddress).totalSupply();
uint256 poolshare = userAddAmount[user];
if(reserverthis > 0 && totalsupplylp > 0)
poolshare = newLpbalance.mul(reserverthis).div(totalsupplylp);
The pending reward is then calculated as:
return oneshareFIST
.sub2(takedFIST[user])
.mul(poolshare)
.div(1e18);
The issue is that these two calculations do not necessarily use the same reserve.
During reward accrual:
oneshareFIST ∝ 1 / Reserve_A
During reward claiming:
poolshare ∝ Reserve_B
So the final reward effectively contains:
Reserve_B / Reserve_A
If an attacker can make:
Reserve_A small
and later make:
Reserve_B large
the calculated reward can be amplified.
The attack can be simplified into five steps.
The attacker creates or controls an LP position that participates in the LPBonus reward system.
The attacker manipulates the liquidity state and reduces the relevant MSN reserve.
The reported reserve reached approximately:
89.3278 MSN
The protocol receives FIST rewards and updates:
oneshareFIST += amount * 1e18 / reservethis;
Because the reserve is small, the resulting increase in oneshareFIST becomes unusually large.
The attacker then changes the liquidity state again.
The reserve reportedly increased to approximately:
491.1131 MSN
When UserRemoveLp is executed, the protocol calculates the user’s reward weight using the new, larger reserve.
The previously inflated oneshareFIST is now multiplied by a larger poolshare.
The contract therefore calculates a FIST reward significantly larger than what should have been distributed.
The root cause is not simply “AMM reserve manipulation.”
AMM reserves naturally change as users trade and liquidity moves.
The real problem is that the protocol used different reserve snapshots for different parts of the same reward calculation.
The reward index was created using:
Reserve at reward accrual
while the user’s reward weight was calculated using:
Reserve at reward settlement
There was no guarantee that these values would remain consistent.
This created an attacker-controlled multiplier inside the reward calculation.
This is a good example of why DeFi audits need to look beyond individual Solidity functions.
AddFistFee alone does not immediately look catastrophic.
CalcPendingUser alone also looks relatively simple.
The vulnerability only becomes obvious when we follow the complete accounting flow:
Manipulate Reserve
↓
Accumulate FIST Reward
↓
Update oneshareFIST
↓
Change Reserve Again
↓
Trigger UserRemoveLp
↓
Calculate Reward Using New Reserve
↓
Receive Excess FIST
The problem exists across multiple state transitions.
The reward system should avoid using independently changing AMM reserve values for reward accrual and settlement.
A more robust design would distribute rewards using a consistent accounting unit such as LP shares:
rewardPerShare +=
rewardAmount * 1e18 / totalEligibleShares;
User rewards could then be calculated as:
pending =
userShares
* (rewardPerShare - userRewardDebt)
/ 1e18;
The important principle is simple:
The denominator used when rewards are distributed and the unit used when rewards are claimed should represent the same accounting quantity.
If reserve-based accounting is required, the protocol should use consistent snapshots rather than reading mutable reserve values at different stages.
One useful invariant for reward contracts is:
Total newly created reward claims
<=
Total rewards funded
If depositing 940,000 FIST into a reward system can create more than 1.4 million FIST of new claims, something is fundamentally wrong with the accounting.
When auditing DeFi protocols, special attention should be paid whenever reward calculations depend on mutable external state such as:
The important question is not only:
Can this value be manipulated?
It is also:
Can the protocol use different versions of this value when creating and later settling the same accounting obligation?
That distinction is exactly what made this vulnerability dangerous.
The MCN Labs incident demonstrates how a relatively small accounting inconsistency can turn into a real loss.
The protocol calculated its FIST reward index using one MSN reserve value and later calculated the user’s reward weight using another.
By reducing the reserve during reward accrual and increasing it before settlement, the attacker was able to amplify the reward calculation and extract excess FIST.
At PandaSecurity, we believe these bugs are particularly important because they often do not look dangerous when individual functions are reviewed in isolation.
The Solidity code may execute exactly as intended.
The problem is that the economic equation itself is wrong.
PandaSecurity Team — Smart Contract Security, DeFi Exploit Research, and On-Chain Incident Analysis.
UNCX's token vesting contract lost roughly $87.7K when an attacker exploited a share-accounting flaw in release(), topUpVesting(), and cancel(). We break down the root cause, the exact attack sequence, and what builders should take away from it — without the jargon.
Autheo’s THEO token lost 92% after attackers used a Hyperlane TrustedRelayerIsm misconfiguration to mint 19 million unbacked tokens on Base and extract roughly $1.76 million. Here’s how the attack worked and why single-key security models remain dangerous for cross-chain assets.
Uniswap v4 hooks explained: the PoolManager singleton, flash accounting, and what the Cork and Bunni exploits teach us about hook security. A PandaSecurity deep dive.
An attacker forced a Uniswap V4 vault into a zero-liquidity state, redeemed one minimal share unit, and withdrew roughly 14,838.47 DAI. Panda Security breaks down the transaction, vulnerable code, root cause, and practical fixes.