Aave oracle diversity: feeds, fallbacks, and stale prices
If an Aave price feed stops updating but keeps returning a positive number, does the protocol switch to another oracle? That question matters more to me than how many feed addresses appear in a dashboard. A lending position depends on the prices accepted for both its collateral and its debt.
I checked Aave V3’s Ethereum market, traced the WETH and USDC price sources, and saved a read-only snapshot. The useful findings are the fallback conditions and the difference between a feed’s update age and evidence that its price is wrong. This exercise needs no deposit, approval, or signing key.
Trace the feed behind each reserve
Start with the market’s PoolAddressesProvider, then call getPriceOracle() at a recorded block. Hard-coding an oracle address forever would miss a registry change. The Aave address book supplies the provider and token addresses; the registry implementation shows how the oracle is selected.
At Ethereum block 26,244,283, timestamp October 21, 2026, 23:31:35 UTC, the registry returned oracle 0x54586bE62E3c3580375aE3723C145253060Ca0C2. Its base currency was USD, its price unit was 100000000, and its fallback address was zero. These are observations at one block, not permanent configuration. See the raw snapshot and block record.
| Asset | Configured source | What the source does |
|---|---|---|
| WETH | 0x5424384B256154046E9667dDFaaa5e550145215e | Feed proxy, with description ETH / USD |
| USDC | 0x3f73F03aa83B2A48ed27E964eD0fDb590332095B | Stablecoin price-cap adapter, reading another feed |
The WETH source’s verified proxy code exposes round data. The USDC source’s verified adapter code exposes ASSET_TO_USD_AGGREGATOR() instead. At this block, that getter returned 0xEa674bBC33AE708Bc9EB4ba348b04E4eB55b496b, with description USDC / USD.
That adapter returns the lower of the upstream price and its configured cap. The observed cap was $1.04; the upstream price was below it, so the cap did not change this reading. A cap limits the accepted upside. It does not supply a second price when the underlying feed stops updating.
What actually reaches the fallback
The deployed oracle’s verified getAssetPrice() implementation has these branches for an ordinary reserve asset:
| Source condition | Oracle behavior |
|---|---|
| No source configured | Call the configured fallback |
| Source returns a positive answer | Return that answer |
| Source returns zero or a negative answer | Call the configured fallback |
| Source call reverts | Revert propagates; no catch-and-switch branch |
| Source returns an old, positive answer | No timestamp check in this oracle layer |
The base-currency asset has a separate constant-price branch. An adapter can also impose its own checks before returning an answer, so inspect the entire path before applying this table to a position.
With a zero fallback address, the fallback branch cannot produce a valid decoded price. There is no configured backup oracle at that address. A positive stale answer, however, can still follow the normal return path. These are different failure cases; I would test them separately on a fork before depending on automated repayment.
Read the configuration and an optional upstream feed
Save the following as oracle-check.mjs in a scratch directory and run npm install ethers@6.17.0 there. I tested with that version. Set RPC_URL to an Ethereum mainnet endpoint, then run node oracle-check.mjs > snapshot.json.
The default uses a finalized block. Set BLOCK=26244283 to reproduce the recorded height; the RPC must serve state at that block. The script pins contract reads to that height and records the hash. Its APIs come from the Aave oracle reference and Ethers provider reference.
import { Contract, JsonRpcProvider } from "ethers";
// This limit is a monitoring policy, not an Aave liquidation rule.
function assessRound(answer, updatedAt, blockTime, maxAge) {
if (maxAge <= 0n) throw new Error("maxAge must be positive");
if (answer <= 0n || updatedAt <= 0n || updatedAt > blockTime) {
return { status: "invalid", ageSeconds: null };
}
const age = blockTime - updatedAt;
return { status: age > maxAge ? "stale" : "within-age-limit",
ageSeconds: age.toString() };
}
async function main() {
if (!process.env.RPC_URL) throw new Error("Set RPC_URL");
const rpc = new JsonRpcProvider(process.env.RPC_URL);
try {
if ((await rpc.getNetwork()).chainId !== 1n) {
throw new Error("Expected Ethereum mainnet");
}
const tag = process.env.BLOCK;
const block = await rpc.getBlock(tag && /^\d+$/.test(tag) ? BigInt(tag) : tag || "finalized");
if (!block) throw new Error("Block unavailable");
const at = { blockTag: block.number }; // Pin every contract read.
const registry = new Contract(
"0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e",
["function getPriceOracle() view returns (address)"], rpc);
const oracleAddress = await registry.getPriceOracle(at);
const oracle = new Contract(oracleAddress, [
"function BASE_CURRENCY() view returns (address)",
"function BASE_CURRENCY_UNIT() view returns (uint256)",
"function getFallbackOracle() view returns (address)",
"function getSourceOfAsset(address) view returns (address)",
"function getAssetPrice(address) view returns (uint256)",
], rpc);
const report = {
block: block.number, hash: block.hash, timestamp: block.timestamp,
oracle: oracleAddress, baseCurrency: await oracle.BASE_CURRENCY(at),
baseUnit: (await oracle.BASE_CURRENCY_UNIT(at)).toString(),
fallback: await oracle.getFallbackOracle(at), assets: [],
};
for (const [symbol, asset] of Object.entries({
WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
})) {
report.assets.push({ symbol, asset,
source: await oracle.getSourceOfAsset(asset, at),
priceRaw: (await oracle.getAssetPrice(asset, at)).toString() });
}
// Set FEED only after tracing a source to its actual price-feed proxy.
if (process.env.FEED) {
const limit = BigInt(process.env.MAX_AGE_SECONDS || "0");
const feed = new Contract(process.env.FEED, [
"function decimals() view returns (uint8)",
"function latestRoundData() view returns (uint80,int256,uint256,uint256,uint80)",
], rpc);
const [roundId, answer, , updatedAt] = await feed.latestRoundData(at);
report.feed = { address: process.env.FEED,
decimals: (await feed.decimals(at)).toString(),
roundId: roundId.toString(), answer: answer.toString(),
updatedAt: updatedAt.toString(),
...assessRound(answer, updatedAt, BigInt(block.timestamp), limit) };
}
// Refuse a mixed snapshot if the RPC changed the block at this height.
if ((await rpc.getBlock(block.number))?.hash !== block.hash) {
throw new Error("Block hash changed; discard this run");
}
console.log(JSON.stringify(report, null, 2));
} finally {
rpc.destroy();
}
}
main().catch(() => {
console.error("Snapshot failed; check RPC, chain, block, feed ABI and age limit");
process.exitCode = 1;
});
For the optional round read, set FEED to the traced feed proxy and MAX_AGE_SECONDS to your monitoring limit. For example, I ran both 0x5424384B256154046E9667dDFaaa5e550145215e and 0xEa674bBC33AE708Bc9EB4ba348b04E4eB55b496b at the recorded block with a synthetic one-hour limit, MAX_AGE_SECONDS=3600.
Do not point FEED at the USDC adapter: its interface has no latestRoundData(). An unavailable method or RPC error fails the run. Leaving FEED unset collects configuration only and makes no freshness assessment. These RPC reads incur no on-chain transaction fee; an RPC provider may charge for requests.
Update age is only one part of lag
The round timestamp is when the feed was updated. Subtracting it from the pinned block timestamp gives update age at that block. It does not measure how far the answer differs from an executable market price. The field definitions and per-feed decimals are in the Chainlink API reference.
| Feed | Answer, normalized to USD | Update age | One-hour test policy |
|---|---|---|---|
| ETH / USD | $2,479.04801894 | 1,584 seconds (26m 24s) | Within age limit |
| USDC / USD | $0.99983478 | 31,776 seconds (8h 49m 36s) | Stale under this policy |
These measured values are in the saved snapshot. The effective Aave prices matched the corresponding feed answers at that block. Divide Aave’s price by BASE_CURRENCY_UNIT; use the feed’s own decimals() for its answer. USDC’s token decimals do not determine its oracle’s price scale.
The one-hour policy deliberately illustrates a problem with a single global timer. Chainlink price feeds update on deviation and heartbeat triggers; different feeds have different settings. A stable price can go longer between updates without establishing a failure. Check the exact feed’s documented parameters before selecting an alert limit. Chainlink update model
I did not verify each feed’s heartbeat or measure market-price error here. The USDC flag therefore means “older than my test limit,” not “the oracle failed.” Conversely, a recent timestamp cannot prove a correct price. To investigate actual price lag, sample over time and compare with a separately sourced, timestamped market reference for the same asset and quote currency.
The live run exercised both feed reads. Local boundary checks also covered zero and negative answers, missing and future timestamps, the exact age limit, an exceeded limit, and invalid limits. Those checks validate the helper’s classifications, not the protocol’s resilience during an outage.
Diversity requires tracing shared dependencies
For this pair, the inventory found two feed paths, one cap adapter, and no configured fallback. Both feed paths use Chainlink proxy contracts. That does not establish independent operators, market-data vendors, or upgrade authorities. Chainlink describes several aggregation layers; distinct proxy addresses alone do not describe how much their inputs overlap. Data aggregation layers
My next evidence table would record each underlying aggregator, its operators where disclosed, market-data dependencies, proxy owner, adapter permissions, and any shared infrastructure. The snapshot already includes the underlying aggregator addresses. Unknown overlap stays unknown; I would not turn it into a numerical diversity score.
A second RPC can detect provider disagreement or improve read availability. If both RPCs read the same feed, they still inherit that feed’s pricing failure. Likewise, adding a different asset does not automatically give a lending position an independent valuation path.
Risk analysis: a small position still depends on both prices
- Technical: Source changes, proxy upgrades, adapter logic, and upstream failures can change the accepted price. A timestamp reader does not establish that the full path rejects stale data. This review covers the named Ethereum market; an L2 review also needs its sequencer checks and recovery behavior. Sequencer uptime considerations
- Economic: Health factor depends on collateral value and debt value. In a synthetic position with $500 collateral, an assumed 80% liquidation threshold, and $300 debt, health factor is 1.33. A 20% collateral-price drop takes it to about 1.07; if debt value also rises 10%, it falls to about 0.97. These are arithmetic assumptions, not current reserve parameters. Aave documents the formula and liquidation eligibility below 1. Health factor and liquidations
- Operational: A stalled RPC can keep serving an old chain view. Check block progress and wall-clock delay as well as feed age before using this as a live monitor. Gas spikes, unavailable liquidity, or a reverting price read can also interfere with an exit. An alert does not guarantee repayment or withdrawal will execute.
What this changes before borrowing
The read-only check was enough to reject an assumption: this market did not have an independent fallback configured at the sampled block. It also showed why I need to trace adapters before asking for timestamps. The remaining gaps are feed-specific update policies, shared upstream dependencies, and behavior during a failure.
I would resolve those gaps with repeated observations and fork tests before adding debt. The $500 example above can stay entirely simulated. If I later fund a trial, I would cap the wallet below $1,000, keep gas outside the position, and choose the debt size only after testing the relevant price shocks. Splitting funds across markets would count as oracle diversification only after their dependency maps show what failures the split actually avoids.