Another day, another nine-figure headline — except this one wasn’t nine figures. Likwid lost 74.31 BNB, which is small by exploit standards, and that’s exactly what makes it worth studying. This wasn’t a flash-loan-oracle masterpiece or a governance takeover. It was one struct field that nobody assigned.
At Panda Security, we keep saying the same thing: the most expensive bugs are rarely the cleverest ones. They’re the boring ones that sit in plain sight because everyone assumes someone else already checked. Likwid is case in point.
Let’s break it down.
Before we talk about the bug, it helps to understand the machine. Likwid runs a leveraged margin trading system on BNB Chain, split into two core contracts:
realReserves — the actual tokens held.pairReserves — the AMM pair reserves, used for pricing swaps.mirrorReserves — the borrow/demand side.lendReserves — the lend/collateral supply side.truncatedReserves — a smoothed baseline for price movement.The key architectural detail: the vault doesn’t calculate anything about a margin operation on its own. The position manager computes a MarginBalanceDelta and passes it in. The vault applies it. That’s a clean separation of concerns — and, as we’ll see, a sharp knife.
struct MarginBalanceDelta {
MarginActions action;
bool marginForOne;
uint128 marginTotal;
uint256 marginFeeAmount;
uint256 swapFeeAmount;
BalanceDelta marginDelta;
BalanceDelta mirrorDelta;
BalanceDelta pairDelta; // <- the field that matters
BalanceDelta lendDelta;
BalanceDelta fundsDelta;
}
If you’ve audited anything in this style — Uniswap v4 hooks, AMM accounting engines, delta-based vaults — alarm bells should already be ringing. When a caller supplies the price-moving deltas, every single one of them has to be present on every path. Miss one and you’ve built an arbitrage printer.
Likwid missed one.
LikwidMarginPosition.margin, forcing the leverage=0 (collateral + borrow) branch.SwapMath.getAmountOut(pairReserves, ...).pairReserves — the swap’s price impact is never written back to the pool.realReserves.The vault’s reserve consistency check passed the entire time. Collateral flowed into lendReserves, debt flowed into mirrorReserves, and the pair reserves sat flat. On paper, the books balanced.
Here is the vulnerable function, essentially verbatim from src/LikwidMarginPosition.sol:
function _executeAddCollateralAndBorrow(
IMarginPositionManager.MarginParams memory params,
PoolState memory poolState,
MarginPosition.State storage position,
MarginBalanceDelta memory delta,
uint256 minBorrowLevel
) internal returns (uint256 borrowAmount) {
(uint256 borrowMaxAmount,) = SwapMath.getAmountOut(
poolState.pairReserves, poolState.lpFee, !position.marginForOne, params.marginAmount
);
if (minBorrowLevel > PerLibrary.ONE_MILLION) {
borrowMaxAmount = Math.mulDiv(borrowMaxAmount, PerLibrary.ONE_MILLION, minBorrowLevel);
}
uint256 borrowRealReserves = poolState.realReserves.reserve01(!position.marginForOne);
borrowMaxAmount = Math.min(borrowMaxAmount, borrowRealReserves * 20 / 100);
if (params.borrowAmount == type(uint256).max) params.borrowAmount = borrowMaxAmount.toUint128();
if (params.borrowAmount > borrowMaxAmount) BorrowTooMuch.selector.revertWith();
borrowAmount = params.borrowAmount;
// ... cumulative values, mirror ratio checks, position.update ...
int128 amount = -params.marginAmount.toInt128();
if (position.marginForOne) {
amount1Delta = amount;
amount0Delta = borrowAmount.toInt128();
delta.lendDelta = toBalanceDelta(0, amount);
delta.mirrorDelta = toBalanceDelta(-borrowAmount.toInt128(), 0);
} else {
amount0Delta = amount;
amount1Delta = borrowAmount.toInt128();
delta.lendDelta = toBalanceDelta(amount, 0);
delta.mirrorDelta = toBalanceDelta(0, -borrowAmount.toInt128());
}
delta.marginDelta = toBalanceDelta(amount0Delta, amount1Delta);
// NOTE: delta.pairDelta is never assigned here.
}
Notice what’s set: lendDelta, mirrorDelta, marginDelta. Notice what’s missing: pairDelta.
Because MarginBalanceDelta is a memory struct, pairDelta defaults to zero. And the vault does exactly what it’s told:
// LikwidVault -> Pool.margin
ReservesLibrary.UpdateParam[] memory deltaParams = new ReservesLibrary.UpdateParam[](4);
deltaParams[0] = ReservesLibrary.UpdateParam(ReservesType.REAL, marginDelta + protocolDelta);
deltaParams[1] = ReservesLibrary.UpdateParam(ReservesType.PAIR, params.pairDelta + protocolDelta);
deltaParams[2] = ReservesLibrary.UpdateParam(ReservesType.LEND, params.lendDelta);
deltaParams[3] = ReservesLibrary.UpdateParam(ReservesType.MIRROR, params.mirrorDelta);
With pairDelta == 0, the pair reserves never move. The next call to getAmountOut(pairReserves, ...) reads the exact same reserves and returns the exact same quote. Repeat 14 times. That’s the whole exploit.
This is the part that makes the bug almost embarrassing — and genuinely instructive.
Look at the leverage > 0 branch in the same file:
if (position.marginForOne) {
delta.marginDelta = toBalanceDelta(0, amount);
delta.pairDelta = toBalanceDelta(-borrowAmount.toInt128(), marginWithoutFee.toInt128());
delta.lendDelta = toBalanceDelta(0, lendAmount);
delta.mirrorDelta = toBalanceDelta(-borrowAmount.toInt128(), 0);
} else {
delta.marginDelta = toBalanceDelta(amount, 0);
delta.pairDelta = toBalanceDelta(marginWithoutFee.toInt128(), -borrowAmount.toInt128());
delta.lendDelta = toBalanceDelta(lendAmount, 0);
delta.mirrorDelta = toBalanceDelta(0, -borrowAmount.toInt128());
}
_executeAddLeverage sets pairDelta. The close, repay, and liquidateBurn paths set pairDelta. Only _executeAddCollateralAndBorrow — the leverage=0 case — does not.
We checked the repository history: this omission existed from at least October 2025 through the latest main (January 2026). It was never a regression or a merge conflict. It was simply never written.
Our honest take: this is what happens when a codebase grows branch by branch. Each new path copies its neighbors, and the one field that isn’t required by the compiler to be set gets silently dropped. Solidity won’t warn you. Tests won’t warn you unless someone wrote the exact assertion for this exact path. And a delta struct has no invariant baked in that says “if you borrow against pair pricing, you must move pair reserves.”
Likwid’s vault does have a consistency check. _updateReservesConsistent enforces:
realReserves + mirrorReserves == pairReserves + lendReserves - insuranceFunds
So why didn’t it revert? Because the leverage=0 deltas happen to satisfy it. Let’s verify the change form for marginForOne == false:
marginDelta = (-marginAmount, +borrowAmount)mirrorDelta = (0, -borrowAmount)lendDelta = (-marginAmount, 0)pairDelta = 0The required identity reduces to marginDelta + mirrorDelta == lendDelta, and:
(-marginAmount, +borrowAmount) + (0, -borrowAmount) = (-marginAmount, 0) = lendDelta ✓
The books balance. The check is doing its job — it’s just checking the wrong thing. It verifies that the totals reconcile, not that the AMM that determines price was updated. Those are different invariants, and conflating them is a common, dangerous shortcut in delta-based designs.
We’ll say it plainly because it’s the whole point of this post:
if (leverage > 0) and the else branch set different subsets of fields, that asymmetry deserves a written justification. There wasn’t one here.pairReserves to quote while letting the actual transfer flow through realReserves. The moment those decouple, you have an infinite-arbitrage primitive.At Panda Security, our reviews don’t stop at “does the accounting balance.” We build invariant maps for delta-based systems: for each state-changing entry point, we list which reserves must move, then we fuzz the paths that shouldn’t move them and watch for profitable divergence. A single missing assignment like Likwid’s is precisely the kind of bug that falls straight out of that process — no exotic tooling, just disciplined enumeration plus a fuzzer pointed at the right invariant.
We also like to state the uncomfortable truth: audits reduce risk, they don’t delete it. Likwid had remediations and audit fixes in its commit history, and this path still slipped through. That’s not a reason to skip audits — it’s a reason to demand continuous review and on-chain monitoring, not a one-time PDF.
0x90bde1e0bb16b3deeb9d638acf8d01f19fd2f31e0xc63fb27f52ed8d06673c60c3075b2d3bd26cf4aa0x6bec0c1dc4898484b7f094566ddf8bc82ed7abe80x065d449ec9d139740343990b7e1cf05fa830e4ba74 BNB is a rounding error to most funds. But the bug behind it is a masterclass in why “it compiles” and “the books balance” are not security arguments. A struct field defaulted to zero, a branch that forgot to fill it in, and an AMM that priced every borrow off reserves nobody updated. That’s it.
If your protocol moves value with caller-supplied deltas, sit down with a spreadsheet today and map every field to every code path. The alternative is writing this exact post-mortem about your own contract — and Panda Security would rather help you before that happens than after.
Found this useful? Follow Panda Security for more Web3 incident breakdowns and practical smart contract audit write-ups.
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.