Oracles & Price Feeds
The second pillar of every lending interview. How prices reach the chain, how attackers manipulate them, and how to integrate them without making your protocol the next post-mortem.
Oracle categories
"Oracle" covers several distinct designs. A senior engineer distinguishes between them automatically.
| Category | How it works | Strength | Weakness |
|---|---|---|---|
| Push (e.g., Chainlink Price Feeds) | Off-chain network signs prices; an on-chain aggregator stores the latest one when deviation/heartbeat triggers | Always-on read; simple integration | Heartbeat lag; updates only on threshold |
| Pull (e.g., Pyth) | Signed price updates served via a relay; consumer posts an update at use time | Fresh price on demand; cheaper publisher infra | UX/keeper burden; staleness on the wire |
| On-chain TWAP (e.g., Uniswap v3 oracle) | Geometric mean of price observations stored on-chain by the DEX itself | Manipulation-resistant over windows; no off-chain trust | Lags real price; expensive to query deeply; thin pools are still vulnerable |
| Off-chain attested (e.g., RedStone, API3, Chronicle) | Prices signed off-chain; consumer verifies signatures at use time | Many sources; flexible | Liveness and signature trust assumptions |
| Optimistic (e.g., UMA OO) | Anyone proposes a price; disputable during a window | Strong for tail / exotic assets | Latency in hours/days; not for hot path |
Push vs pull — the integration shape
// Push — Chainlink AggregatorV3 read
interface AggregatorV3 {
function latestRoundData() external view returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
function decimals() external view returns (uint8);
}
function getPrice(AggregatorV3 feed) internal view returns (uint256) {
(, int256 answer,, uint256 updatedAt,) = feed.latestRoundData();
if (answer <= 0) revert BadOraclePrice();
if (block.timestamp - updatedAt > MAX_STALENESS) revert StaleOracle();
return uint256(answer);
}
// Pull — Pyth-style update-then-read
interface IPyth {
function updatePriceFeeds(bytes[] calldata updateData) external payable;
function getPriceUnsafe(bytes32 id) external view returns (PythStructs.Price memory);
function getUpdateFee(bytes[] calldata) external view returns (uint256);
}
function borrowWithFreshPrice(bytes[] calldata pythUpdate, uint256 amount) external payable {
uint256 fee = pyth.getUpdateFee(pythUpdate);
pyth.updatePriceFeeds{value: fee}(pythUpdate);
PythStructs.Price memory p = pyth.getPriceUnsafe(PYTH_ETH_USD);
if (block.timestamp - p.publishTime > MAX_STALENESS) revert StaleOracle();
if (p.price <= 0) revert BadOraclePrice();
// ... use p.price ...
}
Push oracles externalize the freshness problem to the publisher network. Pull oracles externalize it to the transaction sender. Neither makes staleness go away — they just decide who pays for it. Your protocol still has to check the timestamp it ends up reading.
TWAP construction
The Uniswap v3 oracle stores a circular buffer of cumulative tick observations. Reading a TWAP over [t-Δ, t] is two lookups and an arithmetic mean of ticks (geometric mean of prices, because tick is log-price).
// Read a TWAP from a Uniswap v3 pool
function consult(address pool, uint32 secondsAgo) internal view returns (int24 avgTick) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = secondsAgo;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives, ) = IUniswapV3Pool(pool).observe(secondsAgos);
int56 delta = tickCumulatives[1] - tickCumulatives[0];
avgTick = int24(delta / int56(uint56(secondsAgo)));
// Round towards negative infinity (Solidity rounds towards zero by default)
if (delta < 0 && delta % int56(uint56(secondsAgo)) != 0) avgTick--;
}
// Convert tick to a price (token1/token0 in Q64.96 form)
function tickToSqrtPrice(int24 tick) internal pure returns (uint160) {
return TickMath.getSqrtRatioAtTick(tick);
}
Subtleties senior interviewers will probe:
- Cardinality. Pools default to cardinality 1 (only the latest observation). You must
increaseObservationCardinalityNext(N)to support a longer window. Forget this and your TWAP reverts on long lookbacks. - Geometric vs arithmetic. Ticks are log-prices; the average tick is the geometric mean of prices, which is what you want for symmetric returns.
- Window choice. Short windows (≤5 min) are cheap to manipulate. Long windows (≥30 min) lag the real price during fast moves. Lending protocols typically pick something like 10-30 min.
- Inverted pairs. If your pool is WETH/USDC vs USDC/WETH, the price math inverts. Check token0/token1 ordering.
Oracle attack vectors
| Attack | How | Mitigation |
|---|---|---|
| Spot manipulation | Attacker uses flash loan to imbalance an AMM, reads the manipulated spot, profits | Use TWAP or non-AMM oracle |
| Donation / first-supplier inflation | Attacker mints 1 share, donates large amount to inflate share price, victim deposit rounds down to 0 shares | Virtual shares / dead shares pattern (see ch. 08) |
| Stale-price arb | Oracle has not updated; real price moved; attacker borrows/repays at stale price | Strict staleness check; fallback oracle |
| Oracle outage | Source feed pauses (sequencer down, off-chain network outage) | Pause protocol or freeze liquidations during outage |
| Liquidity squeeze | Attacker drains DEX liquidity so TWAP moves easily | Cross-check with second oracle source |
| Sequencer downtime (L2) | L2 sequencer halts; on-chain price stuck; users cannot react | Grace period check via Chainlink L2 sequencer feed |
| Confidence-interval games (Pyth) | Attacker uses a moment when confidence is wide | Reject prices when conf / price > threshold |
Hardening patterns
A production oracle adapter typically composes several of these:
contract HardenedOracle {
AggregatorV3 public immutable primary;
AggregatorV3 public immutable fallback_;
IUniswapV3Pool public immutable twapPool;
uint256 public constant MAX_STALENESS = 1 hours;
uint256 public constant MAX_DEVIATION_BPS = 200; // 2%
uint32 public constant TWAP_WINDOW = 30 minutes;
AggregatorV3 public immutable sequencerUptime; // L2 only
function readPrice() external view returns (uint256 price) {
// 1) L2 sequencer grace check
if (address(sequencerUptime) != address(0)) {
(, int256 answer, uint256 startedAt,,) = sequencerUptime.latestRoundData();
if (answer == 1) revert SequencerDown();
if (block.timestamp - startedAt < 1 hours) revert SequencerGrace();
}
// 2) Primary
uint256 p1 = _read(primary);
// 3) TWAP cross-check
uint256 pTwap = _twap(twapPool, TWAP_WINDOW);
if (_deviation(p1, pTwap) > MAX_DEVIATION_BPS) {
// 4) Disagree -> fall back
uint256 p2 = _read(fallback_);
if (_deviation(p1, p2) > MAX_DEVIATION_BPS) revert OracleDisagreement();
return p2;
}
return p1;
}
}
The standard checklist:
- Staleness check. Reject prices older than a sane bound for the asset (volatile asset: ~hour; stable: ~day).
- Positivity check. Reject ≤ 0 prices.
- Sanity bounds. Hard min/max prices catch catastrophic feed corruption.
- Cross-check. Compare against a second source; revert or fall back on large deviation.
- L2 sequencer check. On Arbitrum, Optimism, Base — read the official sequencer-uptime feed and apply a grace period.
- Pause mechanism. A guardian role that can freeze borrows / liquidations during an outage.
- Per-market oracle binding. The market commits to an oracle at creation; the oracle cannot be changed under the market.
"I'll just use Chainlink and trust it." Chainlink is robust but not infallible — it has deviation thresholds, heartbeats, and L2 sequencer dependencies. A senior engineer treats every oracle as a defense-in-depth problem: primary + cross-check + staleness + outage path.
Cross-chain oracle considerations
Multi-chain protocols inherit the oracle properties of each chain. A few wrinkles:
- Different feed addresses per chain. The oracle interface is the same but the deployed address differs. Bake addresses into immutables at deploy time, never hard-code in shared logic.
- Different heartbeats / deviation thresholds. ETH/USD on Ethereum mainnet has a different update profile than on Base. Your staleness bounds need to vary.
- L2-specific risks. Sequencer outages are real. Centralized sequencers create a window where users cannot transact but on-chain price might also be stale.
- Cross-chain price (e.g., LST on L2 priced via L1 redemption rate). You may need a bridge or an attested rate. Treat the bridge as part of the trust surface.
- Wrapped / synthetic assets. Be explicit about whether you price the wrapper or the underlying. The two diverge in stress.
Integrating an oracle into a lending market
In a minimal isolated-market design, the oracle is part of the market parameters and immutable for the life of the market:
interface IOracle {
/// @return price collateral price expressed in loan-token units, scaled to ORACLE_SCALE
function price() external view returns (uint256);
}
uint256 constant ORACLE_SCALE = 1e36; // chosen so that:
// collateralValue (loan-token units, scaled to loanDecimals)
// = collateral * price / ORACLE_SCALE
// regardless of the decimals of collateral or loan token.
Two design implications:
- Oracle as a contract per market. Each market commits to its own oracle adapter — typically a per-pair deployment of a factory. This makes oracle choice a permissionless decision (anyone can deploy an adapter), but the market is locked to one for its life.
- No oracle, no liquidation. If the oracle reverts (staleness, bad price), liquidations cannot happen. This is usually the right default: better to freeze a market than to liquidate at a wrong price. But it means a malicious or buggy oracle adapter can grief borrowers. Curation by vaults / risk teams matters.
Trade-offs to defend either side of
- Push vs pull: push wins on UX simplicity; pull wins on freshness and publisher cost.
- Chainlink-only vs multi-source: Chainlink-only is simpler and almost always good enough; multi-source adds robustness for high-TVL or exotic markets.
- Long TWAP vs short TWAP: long resists manipulation; short tracks real price during sharp moves (which is when you most need accurate liquidations).
- Immutable oracle per market vs governance-replaceable: immutable removes a governance attack vector; replaceable lets you fix a bad adapter.
- Per-asset feeds vs derived (LP, LST) feeds: per-asset is simpler; derived is necessary for many real collaterals (cbETH, stETH, GLP) and requires careful "manipulation-resistant" derivation logic.
Answer in three beats: (1) the primary feed I'd reach for, (2) the cross-check, (3) the failure path. "Chainlink ETH/USD as primary. Uniswap v3 30-min TWAP cross-check on the canonical pool. Pause borrows + freeze liquidations if either is stale, with a guardian that can manually unpause once the feed recovers." That answer is correct for ~70% of markets.