Level Finance: The Referral Code Exploit That Drained $1.1 Million
In May 2023, Level Finance—a perpetual DEX on BNB Chain—lost $1.1 million because their referral reward contract tracked which epoch a user had claimed, but never tracked how much they had claimed. The bug was quiet: for ordinary users claiming once, it worked perfectly. The issue only surfaced when someone called the claim function repeatedly across different epochs.
The Referral System
Level Finance ran a referral program. When a referred user traded, the referrer accumulated rewards. Those rewards could be claimed at any time.
The claim function looked like this:
contract LevelReferral {
mapping(address => uint256) public referralRewards; // Total rewards earned
mapping(address => uint256) public claimedEpoch; // Last epoch claimed
function claimReward(uint256 epoch) external {
require(claimedEpoch[msg.sender] < epoch, "Already claimed");
uint256 reward = referralRewards[msg.sender] * rewardRate;
claimedEpoch[msg.sender] = epoch;
rewardToken.transfer(msg.sender, reward);
}
}
The guard claimedEpoch[msg.sender] < epoch was designed to prevent double-claiming. It checks that the user hasn’t claimed this epoch yet—but it checks epoch numbers, not token amounts.
The Oversight
The function tracked which epoch was last claimed. It did not track how many tokens had been transferred out. referralRewards[msg.sender] was read each time to calculate the reward, but it was never decremented.
An attacker who had accumulated referral rewards of, say, 100 LVL tokens could:
- Call
claimReward(1)→ receives 100 LVL,claimedEpochset to 1 - Call
claimReward(2)→claimedEpochis 1, which is less than 2, so the check passes → receives 100 LVL again - Call
claimReward(3)→ same logic → receives 100 LVL again - Continue for every epoch until the reward pool is empty
Each call dispensed the full referralRewards balance as if no previous claim had happened, because the underlying reward balance was never reduced.
The State Tracking Error
The contract was missing a mapping between what was earned and what had already been paid out. Two common correct implementations:
Track total claimed:
mapping(address => uint256) public totalClaimed;
function claimReward() external {
uint256 claimable = referralRewards[msg.sender] - totalClaimed[msg.sender];
require(claimable > 0, "Nothing to claim");
totalClaimed[msg.sender] += claimable;
rewardToken.transfer(msg.sender, claimable);
}
Zero out on claim:
mapping(address => uint256) public pendingRewards;
function claimReward() external {
uint256 reward = pendingRewards[msg.sender];
require(reward > 0, "Nothing to claim");
pendingRewards[msg.sender] = 0; // Zero before transfer
rewardToken.transfer(msg.sender, reward);
}
Both approaches enforce that tokens paid out reduce the balance available for future claims. The original Level Finance contract did neither.
Why the Epoch Framing Was Misleading
The epoch system gave the contract a veneer of correctness. Epochs imply distinct time periods, and preventing double-claiming within a single epoch sounds like a complete solution. But the epoch number is just a counter. The guard claimedEpoch[msg.sender] < epoch only asks “have you claimed in a higher epoch than this one?” It says nothing about whether you’ve already received payment for the same underlying referral rewards in a lower epoch.
The attacker needed only to increment their epoch argument with each call. Every call was to a “new” epoch, so every call passed the guard. The invariant that mattered—total payouts cannot exceed total earnings—was never checked at all.
The Larger Pattern
Level Finance is a clear example of a reward accounting failure, but the pattern generalizes. Any time a contract tracks a boolean or ordinal (claimed/unclaimed, epoch number, last action timestamp) as a proxy for an economic quantity (tokens owed, tokens paid), there’s potential for the proxy to be satisfied while the underlying invariant is violated.
Correct reward accounting requires arithmetic: earned minus paid must always be non-negative, and paying out must always reduce the payable amount. A mapping of claimedEpoch is not arithmetic. It’s a marker that an epoch was visited, not an accounting of what was transferred.
The CEI (Checks-Effects-Interactions) pattern, which the fixed version follows, also matters here: updating totalClaimed before calling rewardToken.transfer prevents reentrancy from compounding the problem. The original contract transferred tokens after updating only a non-financial state variable, which would have made it vulnerable to reentrancy too, had the token been a malicious ERC-20.
Peripheral Features, Core Risk
The $1.1 million loss came from a referral system—a growth feature, not core trading logic. Referral programs tend to receive less audit scrutiny than vaults or AMM logic. They’re perceived as lower-stakes, easier to reason about, and less likely to be exploited.
That perception is wrong. Reward contracts hold protocol funds. Claim functions transfer those funds out. If the claim logic is incorrect, the funds are at risk regardless of how simple the feature appears. Security rigor needs to scale with what a contract can do, not with how central that feature is to the product narrative.