Section B · Technical Core · Secondary

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.

CategoryHow it worksStrengthWeakness
Push (e.g., Chainlink Price Feeds)Off-chain network signs prices; an on-chain aggregator stores the latest one when deviation/heartbeat triggersAlways-on read; simple integrationHeartbeat lag; updates only on threshold
Pull (e.g., Pyth)Signed price updates served via a relay; consumer posts an update at use timeFresh price on demand; cheaper publisher infraUX/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 itselfManipulation-resistant over windows; no off-chain trustLags 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 timeMany sources; flexibleLiveness and signature trust assumptions
Optimistic (e.g., UMA OO)Anyone proposes a price; disputable during a windowStrong for tail / exotic assetsLatency 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 ...
}
The senior framing

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

AttackHowMitigation
Spot manipulationAttacker uses flash loan to imbalance an AMM, reads the manipulated spot, profitsUse TWAP or non-AMM oracle
Donation / first-supplier inflationAttacker mints 1 share, donates large amount to inflate share price, victim deposit rounds down to 0 sharesVirtual shares / dead shares pattern (see ch. 08)
Stale-price arbOracle has not updated; real price moved; attacker borrows/repays at stale priceStrict staleness check; fallback oracle
Oracle outageSource feed pauses (sequencer down, off-chain network outage)Pause protocol or freeze liquidations during outage
Liquidity squeezeAttacker drains DEX liquidity so TWAP moves easilyCross-check with second oracle source
Sequencer downtime (L2)L2 sequencer halts; on-chain price stuck; users cannot reactGrace period check via Chainlink L2 sequencer feed
Confidence-interval games (Pyth)Attacker uses a moment when confidence is wideReject 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.
The classic mistake

"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:

  1. 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.
  2. 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.
When asked "what oracle would you use for X?"

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.