Inside the Likwid Exploit: One Missing pairDelta, 74 BNB Gone | PandaSecurity
Web3 Security

Inside the Likwid Exploit: One Missing pairDelta, 74 BNB Gone

PandaSecurity Team •
#Web3 Security#DeFi Exploit#Smart Contract Audit
Post-mortem diagram of the Likwid margin exploit showing the missing pairDelta assignment

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.

What Likwid Actually Is

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:

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.

The Attack Path, Short Version

  1. The attacker repeatedly called LikwidMarginPosition.margin, forcing the leverage=0 (collateral + borrow) branch.
  2. That branch caps the borrow amount with SwapMath.getAmountOut(pairReserves, ...).
  3. But the borrow never updates pairReserves — the swap’s price impact is never written back to the pool.
  4. Every one of the 14 cycles received the same fixed quote (4.7857 BNB): borrowing at the first trade’s marginal price, with zero price impact.
  5. Roughly 211.8M TOKEN was settled at that stale price, draining BNB out of realReserves.
  6. Profit: 74.31 BNB.

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.

Root Cause: The Leverage=0 Branch Forgot pairDelta

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.

The Tell: Asymmetric Assignment Across Branches

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.”

Why the Vault’s Safety Check Didn’t Save It

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:

The 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.

Lessons for Auditors and Builders

We’ll say it plainly because it’s the whole point of this post:

  1. Every delta field needs a per-path checklist. If a struct field influences price, enumerate every function that should set it and diff them against every function that does. This is mechanical, boring, and exactly how you’d have caught Likwid.
  2. Asymmetric branches are red flags. When if (leverage > 0) and the else branch set different subsets of fields, that asymmetry deserves a written justification. There wasn’t one here.
  3. Conservation checks are not price checks. A vault that balances to the wei can still be drained if the oracle or AMM reserves it reads are stale. Validate both.
  4. Pricing reserves and custody must move atomically. Likwid used pairReserves to quote while letting the actual transfer flow through realReserves. The moment those decouple, you have an infinite-arbitrage primitive.
  5. Don’t trust caller-supplied deltas blindly — or, if you must, make them provably complete. The vault is only as safe as the weakest manager that can call it.

How Panda Security Thinks About This

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.

Timeline of Addresses

Closing Thoughts

74 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.

Related articles

← Back to Blog