BNB Chain
Price manipulation

LML's $950K Loss and How Olympix Would Have Prevented It

Loss
Loss
Exploit date
March 31, 2026

In the last hours of March 31, 2026, someone on BNB Chain walked away with 950,370.69 USDT from the LML staking system, and LML itself fell 99.6%. The protocol's staking contract had paid out rewards exactly the way it was written to. It priced those rewards from a number that was up to an hour old, and it let the attacker cash them out at a price that was a few seconds old.

That gap between two prices is the whole story. BlockSec's analysis calls it flawed business logic, and the mechanics show why a staking contract that never touched an oracle could still be drained like one.

Staking BNB for LML, Priced in Dollars

LML ran a staking program on BNB Chain. Users staked BNB through a contract called APower and earned LML as their reward. The reward was worked out in USDT terms first, then converted into LML using a stored LML/USDT price, so a staker earned a dollar-denominated amount paid out in tokens.

The stored price came from an updatePrice() function that read the live spot price from the LML/USDT pool. To stop that price from being refreshed constantly, updatePrice() enforced a 3,600-second cooldown. Once it ran, the stored number stayed put for at least an hour, whatever happened in the pool in the meantime.

Claims ran through APower's receive() function, keyed to msg.sender, so an address could only ever claim its own rewards. When the PROOF contract that held reward tokens ran short, a helper called swapBack() topped it up by moving LML straight out of the LP pair's reserves and then calling sync() on the pair.

An Hour-Old Price Next to a Live One

BlockSec identifies two flaws in the staking contract, and they feed each other.

The first is the mismatch. Rewards accrued against the stored price, which could be up to an hour old, while the LML those rewards paid out could be sold immediately at the live pool price. Nothing in the contract compared the two. If the live price ran far above the stored one, every claim paid out more LML than the dollar reward it represented, and each of those tokens was worth more than the contract assumed.

The second is the refill path. Because swapBack() pulled LML directly out of the pair and then synced it, every payout that needed a refill shrank the pool's LML side outside of normal swap logic. Fewer LML in the pool means a higher LML price, so each claim pushed the spot price even further from the stale stored price the next claim would use.

Put together, the contract had a loop it couldn't see. Pump the pool, claim at the old price, and the claim itself pumps the pool a little more.

Eleven Wallets and a Burned Supply

The attacker set the trap well before the drain. According to BlockSec, they had staked from 11 separate EOAs ahead of time, so 11 addresses were sitting on legitimate staking positions that would accrue rewards when asked.

On the day, the attacker pulled large flash loans of USDT and WBNB from Moolah, Venus, PancakeSwap V4 and a long list of V3 and V2 pools. They called swapAndTrans() on the LML token contract, which converted the token contract's own accumulated LML fees into USDT. That emptied the buffer the reward system would normally have paid from, which meant every payout after it had to come out of the LP pair through swapBack().

Next came the pump. The attacker swapped borrowed USDT into LML and sent the output to a dead address. PeckShield's figures, reported by Crypto Times, put that buy at 309,529,000 USDT, enough to take almost the entire LML side of the PancakeSwap pool and burn it. The goal was never to own LML. It was to make the few LML left in the pool extremely expensive.

Then the 11 wallets went to work. Because receive() only pays the caller, the attacker used EIP-7702 to give each EOA delegated code that their main contract could drive. Each wallet sent a small amount of BNB to APower, which triggered a claim. Inside every claim, updatePrice() was skipped because the cooldown hadn't elapsed, so updateUser() computed rewards against the old, low stored price. sendMining() moved LML from PROOF to APower, swapBack() refilled PROOF by pulling LML out of the pair and syncing it, and claimReward() paid the wallet, which forwarded the tokens to the attacker's contract.

Finally, the attacker sold everything they'd collected back into the drained pool at the inflated price, repaid every flash loan and fee, and kept the difference. PeckShield reported that the exploiter swapped the 950K USDT for 450.6 ETH and deposited it into Tornado Cash. LML, which DexTools showed trading around $50 to $55 before the attack, dropped 99.6%.

Why a Cooldown Became the Attack Surface

The cooldown on updatePrice() was a sensible instinct. Refreshing a reward price from a spot pool on every claim invites anyone with a flash loan to move the price and claim in one transaction. Slowing the refresh down feels like protection.

It protected the wrong side. The stored price was stable, but the price at which rewards could be sold was never stable at all, and the contract treated them as if they were the same number. A cooldown on one input without a bound on its distance from the other turned an hour of delay into an hour-wide window.

The refill path made the window self-widening. A reward system that can only top itself up by pulling tokens from the very pool that sets its exit price is one where every payout moves the market against the protocol. The break only shows up when value is followed through the cooldown, the claim, the refill and the sale in one sequence.

How Olympix Would Have Caught the Price Gap

Olympix reads a contract as states, transitions and entry points. For LML, the important state is the stored reward price, the LP pair's reserves and each staker's accrued reward. The transitions are updatePrice(), the claim path behind receive(), and swapBack(). The engine takes that structure in, infers the invariants it depends on, then maps paths and uses signals to narrow from every path to the feasible ones to the ones that actually touch an invariant.

The invariant LML needed is easy to say. The dollar value a staker can extract from a claim shouldn't exceed the dollar reward they accrued. That property ties the stored price and the live price together, and it's exactly what the contract never enforced.

Static analysis would have surfaced the signal here: a reward calculation that reads a stored price while the payout asset is redeemable at a separately computed market price, with no deviation check between them, plus a refill routine that edits pool reserves outside the swap path. Those are the markers that tell the engine which paths deserve a closer look.

BugPoCer works from the system outward. It starts from an invariant and searches for a sequence of state changes that breaks it. Had the LML team run it during development, the search would have come back with a concrete counterexample very close to March 31: move the pool price inside the cooldown window, claim against the stored price, let the refill pull reserves, and sell the payout at the live price for more than the accrued reward. BugPoCer would have returned that as a sequence of state changes with the minimal conditions that trigger it and a generated Forge test that reproduces it, at the merge where the reward logic was written.

The fix the counterexample points to is small: compare the stored price against the live one before paying a claim and refuse when they drift too far apart, and refill rewards from a source that doesn't move the exit price. Both checks would then have run at every merge and before release.

Two Prices, One Invariant

Any contract that values something with one price and lets it leave at another has to state, in code, how far apart those prices are allowed to be. LML's staking logic held each price carefully and never checked one against the other. To see how Olympix maps those cross-price paths in your own contracts and hands back a working counterexample for each one that breaks, book a technical walkthrough.

On-Chain References

Exploit tx (Mar 31, 2026): 0x805d...5b47

Related exploit tx: 0x70f7...3572

LML staking contract: 0xbe97...ce19

See what Olympix finds on your own code

Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.