A bunch of people on crypto Twitter called this the first "vibe‑coded" smart contract exploit after noticing an AI co‑author tag in the governance PR. Whether or not it's literally the first, it's definitely one of the first high‑profile DeFi blowups where AI co‑authorship became part of the public narrative.

This postmortem is about what actually happened, why the protocol's assumptions made it exploitable, and what the real fix looks like (with the vulnerable snippet + a patched version).

TL;DR

  • Trigger: Moonwell executed governance proposal MIP‑X43 on Feb 15, 2026 18:01 UTC, enabling Chainlink OEV wrapper contracts across core markets on Base and Optimism.
  • Bug: The cbETH oracle was configured to use the cbETH/ETH exchange rate as if it were a cbETH/USD price — so cbETH was reported around $1.12 instead of roughly $2,200.
  • Blast radius: Liquidation bots repaid about $1 of debt to seize roughly 1 cbETH, wiping collateral and leaving Moonwell with ~$1.779M in bad debt (total seized: 1096.317 cbETH).
  • Containment: Within minutes, supply and borrow caps for cbETH were reduced to 0.01, but liquidations continued because the oracle fix required the governance timelock.
  • Fix direction: A follow‑up proposal (MIP‑B57) reverts cbETH's feed to a composite oracle (the correct way to derive cbETH/USD from cbETH/ETH × ETH/USD).

What happened (timeline)

  1. Governance execution introduced the bad price

At 18:01 UTC, Feb 15, MIP‑X43 executed and rolled out new oracle wiring (Chainlink OEV wrappers) across markets on Base and Optimism.

In that rollout, cbETH's oracle configuration accidentally used only the cbETH/ETH rate, but the system expected a USD price. That meant cbETH got treated like it was worth ~$1.12 (because cbETH/ETH was about ~1.12 at the time).

2. Bots liquidated instantly

Once the oracle said "cbETH ≈ $1", accounts using cbETH as collateral became wildly undercollateralized on paper, so liquidation bots started repaying tiny amounts of debt to seize huge amounts of cbETH.

3. Monitoring caught it quickly, but governance couldn't hotfix instantly

By 18:05 UTC, monitoring alerted the team, and they reduced cbETH supply & borrow caps to 0.01 (effectively freezing new activity), but couldn't immediately correct the oracle due to the governance voting + timelock flow — so liquidations kept happening.

4. Final impact

When the dust settled, 1096.317 cbETH had been seized and the protocol had $1,779,044.83 in bad debt across multiple assets (largest chunk denominated in cbETH).

Why it was exploitable (Compound‑style liquidation math)

Moonwell is Compound‑v2‑style. In these systems, liquidation seize amount scales roughly like:

seize ∝ repayAmount * (priceBorrowed / priceCollateral)

If priceCollateral (cbETH) gets incorrectly pushed down by ~99.9%, then (priceBorrowed / priceCollateral) explodes, so repaying ~$1 can seize ~$2,200 of collateral (minus liquidation incentive math details). That's exactly the shape of what happened: bots repaid minimal debt to seize lots of cbETH.

The "vibe‑coded" angle (what's actually true)

People piled on because the governance PR that introduced the configuration had commits tagged:

And an auditor‑ish Twitter thread framed it as possibly the first "vibe‑coded Solidity" hack.

But the root cause is not "AI wrote evil code." It's a classic unit / quote‑currency mismatch — the kind you prevent with:

  • typed oracle configs,
  • invariant tests on a fork,
  • and sanity bounds per asset.

AI or not, this is a process/testing failure.

The exploited part (the exact foot‑gun)

There are two pieces that combined into the incident:

  1. The misconfigured oracle mapping (cbETH set to cbETH/ETH rate feed)

From the MIP‑X43 oracle config list for Base, cbETH was wired like this:

// Base oracle configs (MIP-X43 tooling)
_oracleConfigs[BASE_CHAIN_ID].push(
    OracleConfig("cbETHETH_ORACLE", "cbETH", "MOONWELL_cbETH")
);

This line is the smoking gun: it selects an oracle named cbETHETH_ORACLE for the cbETH market on Base.

In the incident write‑up, the team states the system should have derived cbETH/USD by multiplying cbETH/ETH × ETH/USD, but instead used only cbETH/ETH.

2. The ChainlinkOracle assumes the feed answer is already USD‑denominated

Here's the relevant on‑chain ChainlinkOracle logic (Base deployment), which treats the feed answer as a price and scales it to 1e18:

function getChainlinkPrice(AggregatorV3Interface feed)
    internal view returns (uint256)
{
    (, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
    require(answer > 0, "Chainlink price cannot be lower than 0");
    require(updatedAt != 0, "Round is in incompleted state");

    // Chainlink USD-denominated feeds store answers at 8 decimals
    uint256 decimalDelta = uint256(18).sub(feed.decimals());

    if (decimalDelta > 0) {
        return uint256(answer).mul(10 ** decimalDelta);
    } else {
        return uint256(answer);
    }
}

This is fine if the feed is actually a USD price. But if the feed is a ratio like cbETH/ETH ≈ 1.12, the oracle happily returns ~1.12e18 → interpreted everywhere as $1.12.

The patch (what a correct fix looks like)

Moonwell's follow‑up fix path is essentially: stop feeding a ratio into a USD‑expecting oracle.

Patch A: Revert cbETH to a composite oracle (MIP‑B57)

A follow‑up governance PR (MIP‑B57) explicitly sets the cbETH feed to a cbETH composite oracle on the ChainlinkOracle contract:

"Set feed to cbETH_COMPOSITE_ORACLE for cbETH" (targeting the ChainlinkOracle address)

Conceptually, the patched wiring is:

// ✅ Patched: feed returns cbETH/USD, not cbETH/ETH
chainlinkOracle.setFeed("cbETH", cbETH_COMPOSITE_ORACLE);

That's the minimal correct fix: keep the existing ChainlinkOracle logic, but ensure the feed it reads is truly USD‑denominated.

Patch B: Use a composite oracle that actually produces cbETH/USD

Moonwell already uses a ChainlinkCompositeOracle pattern that multiplies feeds together and exposes a Chainlink‑compatible interface.

Key parts from the verified ChainlinkCompositeOracle contract:

  • It pins decimals = 18 specifically to be compatible with ChainlinkOracle.sol expectations.
  • It computes derived price by multiplying two feed answers and dividing by a scaling factor:
  • It exposes latestRoundData() in a way meant to be consumed like a standard price feed.

Here's the core math that makes the composite oracle work:

uint8 public constant decimals = 18;

// derived = basePrice * multiplierPrice / scalingFactor
function calculatePrice(
    int256 basePrice,
    int256 priceMultiplier,
    int256 scalingFactor
) public pure returns (uint256) {
    return ((basePrice * priceMultiplier) / scalingFactor).toUint256();
}

And here's the intent (straight from the contract comments): for assets like cbETH, you use a base feed (e.g., ETH/USD) and a multiplier (the cbETH/ETH exchange rate), so the composite yields cbETH/USD.

Patch C: Make composite feeds compatible with OEV wrappers

One of the reasons composite oracles were being treated specially is that some OEV tooling expected certain round/compatibility behavior. You can literally see this in the original config file comment:

"Composite oracles don't support latestRound(), deferred to follow-up"

A parallel PR (#581) adds a ChainlinkCompositeOEVWrapper specifically for composite oracles, and even includes a commit that switches tracking from latestRound to latestRoundData.

That's the "plumbing" fix that prevents teams from reaching for a non‑USD ratio feed just because it happens to satisfy some wrapper interface quirk.

Extra hardening (so this doesn't happen again)

Even with the composite fix, there are a few defensive moves that would make this class of incident much harder:

  1. Type your oracle configs (USD vs ETH quote)

Right now, the bug class exists because the system can't tell "this feed is a USD price" vs "this feed is an exchange rate."

A simple approach:

enum Quote { USD, ETH }

struct FeedConfig {
    AggregatorV3Interface feed;
    Quote quote;
}

mapping(bytes32 => FeedConfig) public feedConfig;

AggregatorV3Interface public immutable ethUsdFeed;

function getPriceUSD(bytes32 symbol) external view returns (uint256) {
    FeedConfig memory cfg = feedConfig[symbol];

    uint256 px = _read(cfg.feed); // scaled to 1e18

    if (cfg.quote == Quote.ETH) {
        uint256 ethUsd = _read(ethUsdFeed);
        // px is token/ETH, ethUsd is ETH/USD -> token/USD
        px = px * ethUsd / 1e18;
    }

    return px;
}

Now it becomes impossible to "accidentally" treat token/ETH as token/USD.

2. Add per‑asset sanity bounds

cbETH should never be near $1. Add configurable minPrice / maxPrice ranges per asset and revert if violated (or pause borrows/liquidations).

3. Governance simulation should include price invariants

Before executing on mainnet, fork Base + Optimism and assert:

  • new oracle price ≈ old oracle price (within X%)
  • price is within realistic bounds
  • liquidation simulation doesn't seize absurd collateral for tiny repay amounts

If you run that test on a fork, the "cbETH = $1.12" mistake screams instantly.

Closing thought: the real lesson of the "vibe‑coded exploit"

The AI tag made it meme‑able, but the actual underlying failure mode is old as DeFi:

units, quotes, and "assumed invariants" in oracle plumbing are where money goes to die.

Moonwell's own incident summary describes it plainly: cbETH was priced using only the cbETH/ETH rate instead of cbETH/ETH × ETH/USD, which kicked off the liquidation cascade and left ~$1.78M in bad debt.