AntFleet

Retro · moonwell-mipx43-2026-02

Moonwell MIP-X43 oracle bug

Unanimous gate fired on a HIGH-severity oracle bug in PR #578. We caught a sibling of the cbETH config that was exploited — not cbETH itself.

$1.78Mincident · 2026-02-15

AI coauthor

The introducing commit was authored by Ana Bittencourt and coauthored by Claude Opus 4.6 per the commit trailer.

View commit 0898069b7e49 on GitHub →

Outcome A

Both Claude Opus 4.7 and GPT-5 independently flagged a HIGH-severity oracle-feed misconfiguration in PR #578 (MIP-X43). The unanimous gate fired. We did not name cbETH in any finding. The bug we caught — WELL feed wired under xWELL's symbol — is structurally identical to the exploited cbETH config, in the same PR.

Methodology

Repo
moonwell-fi/moonwell-contracts-v2@a9f736700cdc
Introducing PR
9a6738e189fa903bffa0abf10b9dd7ed2fe73a18
Prompt SHA
01607a222a8feddff59746a9f27655e4e69d88c3d02a1fbc8bf9d89150247352
Pipeline
0.1.0 @ c73d30023b2e
Models
claude-opus-4-7, gpt-5
Scanned at
2026-05-21T05:53:21.607Z
On 2026-02-15 Moonwell lost $1.78M to a cbETH oracle misconfiguration introduced in PR #578 (MIP-X43, merged 2026-02-10). We re-ran the unmodified production AntFleet pipeline against PR #578's diff. Both reviewers independently produced a HIGH-severity finding inside the introducing hunks; the unanimous gate fired on one of them: a feed wired under the wrong symbol because the OracleConfig entry used the wrong proxy as the symbol source. The exact bug we flagged was on the WELL/xWELL feed, not the cbETH feed. We do not claim AntFleet would have prevented this exploit — that would require the maintainer to also audit the cbETH entry. We **do** claim that a HIGH-severity, sibling oracle bug in the same PR would have surfaced through our gate before merge.

Reproduce this scan

Requires the AntFleet retro-scan tool — open-source release pending. The prompt SHA and pipeline commit SHA in the methodology block above pin the methodology regardless of when the tool ships.

pnpm exec tsx apps/web/scripts/run-retro-scan.ts \
  --repo moonwell-fi/moonwell-contracts-v2 --pr-sha 9a6738e189fa903bffa0abf10b9dd7ed2fe73a18 \
  --case-id moonwell-mipx43-2026-02 --label "Moonwell MIP-X43 oracle bug" \
  --loss-usd 1780000 --incident-date 2026-02-15

Per-provider findings (verbatim)

Anthropic

claude-opus-4-7

USDC/USDBC share a single oracle but only the second mToken whitelisting takes effect for OEV redemption mapping

low / mediummaintainability

proposals/ChainlinkOracleConfigs.sol:32-37

Both USDC and USDBC entries reference CHAINLINK_USDC_USD. _deployCoreWrappers deduplicates the wrapper deployment via isAddressSet checks (good). However in _wireCoreFeeds, setFeed(symbol, wrapper) is called twice for the same oracle (once with USDC, once with USDBC). Both calls set distinct symbol keys to the same wrapper address—this is correct behavior. The concern is that _validateFeedsPointToWrappers compares ERC20(symbol).symbol() of USDC and USDBC tokens: USDBC's on-chain symbol() returns 'USDbC' (case sensitive). If ChainlinkOracle.getFeed stores by symbol string, the lookup must match exact case used in setFeed; both go through the same ERC20.symbol() call so they should match, but anyone reading the chain config (chains/8453.json defines name 'USDBC') may incorrectly assume the addresses key 'USDBC' matches the on-chain symbol string. No functional bug, but the dual-config pattern is fragile and undocumented except by an inline comment.

Fix

Add an assertion in _wireCoreFeeds that the (oracleName -> wrapper) mapping is consistent across configs sharing the same oracleName, and consider modeling shared-oracle multi-symbol cases explicitly rather than relying on dedup-by-isAddressSet.

Deprecation logic uses try/catch on chainlinkOracle() to distinguish old vs new wrappers — fragile heuristic

medium / mediummaintainability

proposals/mips/mip-x43/mip-x43.sol:132-145

The decision to deprecate-and-replace vs. skip is made by probing whether the existing contract exposes chainlinkOracle(). Any old contract that happens to expose a chainlinkOracle() getter (perhaps with a different semantic, or accidentally introduced) would be treated as already-new and skipped, leaving a stale wrapper wired up. Conversely, a transient RPC/revert during the try call would cause an unintended redeploy. A robust signature would check codehash, version() string, or a sentinel storage variable. This is a build-time risk in proposal deploys rather than runtime, but because the deploy script is the source of truth for which wrappers are 'live', a mis-classification could silently leave an old wrapper as the active feed.

Fix

Introduce an explicit version() or VERSION constant on ChainlinkOEVWrapper and check that instead of relying on a function-existence probe. Alternatively, maintain an explicit list of oracleNames whose wrappers already exist in new-style form (e.g., from MIP-X38) and gate redeployment by that allow-list.

Validation of deprecated wrappers does not assert the deprecated address still has code or is no longer referenced

low / hightest-gap

proposals/mips/mip-x43/mip-x43.sol:360-397

_validateDeprecatedWrappers only checks that the deprecated address differs from the current and is nonzero. It does not assert that no chains/*.json or ChainlinkOracle feed still points at the deprecated address. Given the proposal's purpose is to ensure feeds are rewired to new wrappers, a stronger check would be that for each oracleName, ChainlinkOracle.getFeed(symbol) != deprecatedAddress. This is partially covered by _validateFeedsPointToWrappers (which asserts feed==current) but only over the current oracleConfigs list — any external consumers (e.g. Morpho IRMs) using the deprecated address would not be caught.

Fix

Augment _validateDeprecatedWrappers to enumerate all known consumers (mTokens, Morpho oracles, vaults) and assert none of them still reference the deprecated wrapper address.

x43.md lists MAMO and stkWELL Morpho upgrades but configs only push 'CHAINLINK_MAMO_USD' and 'CHAINLINK_stkWELL_USD' — wrapperName construction may not match deployed proxy address keys

high / mediumbug

proposals/ChainlinkOracleConfigs.sol:90-99

The proxyName 'CHAINLINK_MAMO_USD' is concatenated with '_ORACLE_PROXY' yielding 'CHAINLINK_MAMO_USD_ORACLE_PROXY', which IS present in chains/8453.json. Similarly 'CHAINLINK_stkWELL_USD_ORACLE_PROXY' is present. However, the priceFeedName for MAMO is also 'CHAINLINK_MAMO_USD' (not the impl-deprecated key), which resolves via addresses.getAddress to the *impl* address — checking chains/8453.json, there is no plain key 'CHAINLINK_MAMO_USD' at all (only 'CHAINLINK_MAMO_USD' exists in chains/8453.json — actually it does at 0xeF7541b388a77C1709a3d44BfBfC5c1ED3F0Ac94). It does exist. But this same key on Optimism does not exist. Since BASE_CHAIN_ID is the only chain with Morpho configs, that's fine. The concern: initializeV2 is called with priceFeed=CHAINLINK_MAMO_USD (the raw Chainlink aggregator), while the proxy previously wrapped MORPHO_CHAINLINK_MAMO_USD_ORACLE (0x18d1325e8528E3ceafBf9a77F3bE3f76Fec42D5C). If callers (Morpho markets) expect the proxy to behave as an oracle in MORPHO_CHAINLINK format (price scaled to Morpho's expected 1e36 conventions) but the new implementation now exposes the raw Chainlink feed semantics, this is a silent contract change. Without seeing ChainlinkOEVMorphoWrapper.initializeV2 source, this cannot be confirmed, but is a high-impact risk worth flagging.

Fix

Verify that the underlying price feed passed to initializeV2 (raw Chainlink USD aggregator) yields the same scaled price that Morpho markets and downstream consumers expect from the proxy. If the previous proxy was a MORPHO_CHAINLINK_* (1e36-scaled) oracle, the new ChainlinkOEVMorphoWrapper must replicate the scaling internally. Add explicit price-parity validation in _validateMorphoWrappersState comparing pre/post-upgrade quoted prices.

_updateExistingWrapperFees uses CHAINLINK_WELL_USD_ORACLE_PROXY existence as a Base-only sentinel, but key exists in chains/8453.json regardless of fork — could cause spurious calls when run on wrong fork

medium / mediumbug

proposals/mips/mip-x43/mip-x43.sol:290-309

build() first runs on BASE_FORK_ID (calling _updateExistingWrapperFees once), then switches to OPTIMISM_FORK_ID and calls it again. The function gates the WELL Morpho fee update by isAddressSet of CHAINLINK_WELL_USD_ORACLE_PROXY. Addresses object is constructed from chains/<id>.json indexed by block.chainid, so isAddressSet should return false on Optimism (the key is only in chains/8453.json) — correct behavior assuming Addresses isolates per-chain. However the comment claims 'only on Base, not Optimism' without enforcing it via chain id. If Addresses leaks keys across forks (e.g., a global registry shared across chain ids), the second invocation would also push the action to the Optimism fork's action queue with a Base-only address, producing an invalid governance action. Verify by inspecting Addresses.isAddressSet semantics or by adding an explicit `block.chainid == BASE_CHAIN_ID` guard.

Fix

Replace the isAddressSet sentinel with an explicit `if (block.chainid == BASE_CHAIN_ID)` check, or refactor _updateExistingWrapperFees to take chainId.

mips.json: MIP-X43 entry is missing 'envpath' value while others use empty string consistently — minor schema-consistency issue

low / highmaintainability

proposals/mips/mips.json:1-8

The entry is structurally fine. However mips.json mixes 'envpath' (lowercase) and 'envPath' (camelCase) across entries; tooling may be case-sensitive. MIP-X43's entry uses lowercase 'envpath' which matches the most-recent entries but not all. This is a pre-existing schema inconsistency, but anyone editing tooling should be aware.

Fix

Normalize all entries to a single key case (e.g., 'envPath') in a separate cleanup PR.

OpenAI

gpt-5

WELL feed wired using xWELL symbol due to misconfigured symbol source

high / highbug

proposals/ChainlinkOracleConfigs.sol:66-68

For the Base chain, the WELL market configuration uses xWELL_PROXY as the symbol source. _wireCoreFeeds derives the symbol via ERC20(addresses.getAddress(config.symbol)).symbol(), which returns "xWELL" for the xWELL staking token proxy, not "WELL". This will set the ChainlinkOracle feed under the key "xWELL", while the MOONWELL_WELL market expects the "WELL" symbol. As a result, WELL’s core market will not use the intended OEV wrapper feed. The validation routine also derives the symbol the same (incorrect) way, so it won’t catch this mismatch.

Fix

In proposals/ChainlinkOracleConfigs.sol, change the WELL config symbol source from "xWELL_PROXY" to a token address key that returns the "WELL" symbol, e.g. "GOVTOKEN": _OracleConfig("CHAINLINK_WELL_USD", "GOVTOKEN", "MOONWELL_WELL")_ Alternatively, add a "WELL" token address entry and use that as the symbol source. Also consider hard-coding the feed key for WELL to "WELL" to avoid symbol derivation pitfalls.

Misleading comment: mTokenKey "defaults to MOONWELL_[symbol]" but no defaulting logic implemented

low / highdocs-gap

proposals/ChainlinkOracleConfigs.sol:7-11

The comment documents a default behavior for mTokenKey that is not implemented anywhere in the contract. All usages read mTokenKey directly from the stored configuration. This could mislead future contributors to omit mTokenKey expecting fallback behavior.

Fix

Update the comment to remove the defaulting claim or implement explicit defaulting (e.g., when mTokenKey is empty, derive MOONWELL_[symbol]). Given current usage, updating the comment is simpler and less error-prone.

Variable name FEE_MULTIPLIER is misleading; used as fee basis points (liquidatorFeeBps)

low / highmaintainability

proposals/mips/mip-x43/mip-x43.sol:30-33

The constant name suggests it is a multiplier but it is passed as a basis points value to setLiquidatorFeeBps, representing 3000 bps (30%). This can confuse maintainers and reviewers.

Fix

Rename FEE_MULTIPLIER to LIQUIDATOR_FEE_BPS (or similar) to reflect its actual semantics. No functional change required.

Unanimous gate (intersection)

WELL feed wired using xWELL symbol due to misconfigured symbol source

high / highbug

proposals/ChainlinkOracleConfigs.sol:66-68

For the Base chain, the WELL market configuration uses xWELL_PROXY as the symbol source. _wireCoreFeeds derives the symbol via ERC20(addresses.getAddress(config.symbol)).symbol(), which returns "xWELL" for the xWELL staking token proxy, not "WELL". This will set the ChainlinkOracle feed under the key "xWELL", while the MOONWELL_WELL market expects the "WELL" symbol. As a result, WELL’s core market will not use the intended OEV wrapper feed. The validation routine also derives the symbol the same (incorrect) way, so it won’t catch this mismatch.

Fix

In proposals/ChainlinkOracleConfigs.sol, change the WELL config symbol source from "xWELL_PROXY" to a token address key that returns the "WELL" symbol, e.g. "GOVTOKEN": _OracleConfig("CHAINLINK_WELL_USD", "GOVTOKEN", "MOONWELL_WELL")_ Alternatively, add a "WELL" token address entry and use that as the symbol source. Also consider hard-coding the feed key for WELL to "WELL" to avoid symbol derivation pitfalls.