Price oracle

An independent on-chain price floor that the executor cannot lower - the safety net under every swap.

Why this exists

The executor proposes a minimum acceptable output for each batch swap. That is not the only protection: an on-chain oracle computes its own minimum independently, and the contract enforces max(executor's minimum, oracle's minimum). The executor can only ever make slippage stricter, never looser. If no oracle is configured, the batch reverts, so there is no silent "no protection" path.

Which oracle is deployed right now

The live Base mainnet deployment uses direct Chainlink USD feeds for its launch set, WETH and cbBTC. The manual oracle described at the bottom of this page was a testnet accommodation, because Base Sepolia has no verified price feeds; it is not used on mainnet.

Everything between here and there describes the production oracle design that is now live. The composed implementation remains tested for a future governed asset addition, but no composed asset is enabled at launch.

The contract that matters: quoteMinOut

Every oracle implements one function from IPriceOracle:

function quoteMinOut(address asset, uint256 usdcIn) external view returns (uint256 minOut);

Given a USDC amount, it returns the minimum amount of asset the swap must produce. The interface mandates that implementations revert when an asset is unsupported or a price source is unsafe - they must never return 0 as a silent "no floor." The BatchExecutor then computes:

swapAmount             = actualTotal - executionFee        // net of the USDC fee
oracleMinOut           = priceOracle.quoteMinOut(targetAsset, swapAmount)
adjustedExecutorMinOut = minAmountOut * swapAmount / eligibleTotal   // scaled for skips and fee
effectiveMinOut        = max(adjustedExecutorMinOut, oracleMinOut)
// swap must return at least effectiveMinOut, else revert

Both bounds are derived from swapAmount, the amount actually being swapped, rather than from the gross total pulled. That matters twice over: what is not swapped cannot be bought, so pricing it would inflate the floor, and skipped accounts never enter eligibleTotal, so they cannot dilute the executor's bound either. The same scaling is what nets the fee out of a quote the executor took against the gross amounts it submitted.

That bound is enforced twice. The swap stack receives effectiveMinOut and is expected to enforce it at the DEX, and the BatchExecutor re-checks the returned amount against the same value afterwards. The second check is what holds if a misconfigured router or adapter ignores the minimum it was handed, since the router is a trust boundary. The two components keep distinct roles: oracleMinOut is the protocol floor no executor can lower, while effectiveMinOut is that floor plus any extra tightening the executor asked for.

Two pricing strategies, one dispatcher

Not every asset has a direct USD price feed on Base. The protocol handles this with two oracle implementations behind a router:

BatchExecutor
  calls quoteMinOut(asset, usdcIn)

OracleDispatcher
  maps each asset to the correct oracle
  contains no price logic

ChainlinkPriceOracle
  handles direct asset/USD feeds
  used for WETH and cbBTC

ComposedPriceOracle
  combines the asset/ETH market feed with ETH/USD
  available for a future governed asset addition

The dispatcher only routes. The launch assets use the direct implementation. A future composed asset would retain a separate failure domain and would require the full asset-addition review before activation.

Direct prices - ChainlinkPriceOracle

For assets with a trustworthy direct USD feed (WETH via ETH/USD, cbBTC via cbBTC/USD - no BTC peg is assumed), the floor is a single feed read:

expectedOut = (usdcIn * 10^assetDec * 10^feedDec) / (10^usdcDec * answer)
minOut      = expectedOut * (10000 - slippageBps) / 10000

Each asset is configured with setAssetFeed(asset, feed, assetDecimals, maxStaleness, slippageBps). Staleness is hard-capped at 7 days and slippage at MAX_SLIPPAGE_BPS = 1000 (10%). These are configuration sanity bounds, not the normal per-feed heartbeat or per-batch slippage.

Composed prices - ComposedPriceOracle

Future capability, not a launch route

A token without a direct USD feed may be priced by multiplying a verified market asset/ETH feed by ETH/USD. cbETH and wstETH demonstrate this implementation in tests, but neither is enabled for new strategies at launch.

asset/USD = (basePrice / 10^baseDec) * (quotePrice / 10^quoteDec)

expectedOut = (usdcIn * 10^assetDec * 10^baseFeedDec * 10^quoteFeedDec)
              / (10^usdcDec * basePrice * quotePrice)
minOut      = expectedOut * (10000 - slippageBps) / 10000

Configured with setComposedFeed(asset, baseFeed, quoteFeed, assetDecimals, baseMaxStaleness, quoteMaxStaleness, slippageBps). The base feed is the <asset>/ETH market price; the quote feed is ETH/USD (shared with the WETH direct feed). Each leg is validated independently - if either is stale or invalid, the quote reverts.

The one irreducible risk: market vs exchange-rate feedThe composed base leg must be the market <asset>/ETH feed (e.g. Chainlink "CBETH / ETH"), never a protocol exchange-rate feed (e.g. "wstETH-stETH Exchange Rate"). An exchange-rate feed reports redemption value and does not fall during a market depeg - composing it would hold the floor artificially high while the asset trades down, defeating the protection. The contract cannot tell the two apart on-chain; this is an operator responsibility at deploy time, verified by reading each feed's description() directly on-chain. An off-chain registry once mislabeled wstETH (pointing at "STETH / ETH" ≈ 1.0); a fork test caught it. Rule: trust description(), not the registry.

Staleness - and why one global value would brick the protocol

Chainlink feeds update on a "heartbeat." If a feed hasn't updated within its staleness window, the oracle treats the price as stale and reverts (StalePrice). The catch: different feeds on Base have very different heartbeats, so a single global staleness would be wrong for half the assets.

FeedMeasured heartbeatStaleness windowDeploy parameter
ETH/USD (direct + quote leg)~20 min3600 s (1 h)DEPLOY_ORACLE_MAX_STALENESS / ..._QUOTE_STALENESS
cbBTC/USD (direct)~20 min3600 s (1 h)DEPLOY_ORACLE_MAX_STALENESS
cbETH/ETH, wstETH/ETH (composed base)~24 h90000 s (25 h)DEPLOY_ORACLE_BASE_STALENESS
Why the split mattersThe <asset>/ETH market feeds only update about once a day. If the protocol used a single 3600-second (1-hour) staleness everywhere, every cbETH and wstETH batch would revert as "stale" within an hour of each feed update. Hence the deploy splits staleness per feed: ~1 hour for the fast USD feeds, ~25 hours for the slow market feeds.

Each window is set per-asset at configuration time, and values of 0 or greater than 7 days are rejected. Default deploy values: slippage 200 bps (2%), sequencer grace 3600 s.

L2 sequencer uptime check

On an L2 like Base, if the sequencer goes down and then restarts, oracle prices can be stale-but-recent in a way that's dangerous to trade against. Both oracles run the same guard before trusting any price:

  1. If the sequencer policy was never configured, revert. L1 and local deployments must explicitly disable it with (address(0), 0).
  2. Read the Chainlink L2 Sequencer Uptime feed: answer == 0 means up, 1 means down.
  3. If answer != 0, revert with SequencerDown().
  4. If the round's startedAt == 0 (uninitialized), revert with SequencerDown().
  5. If less than gracePeriod seconds have passed since the sequencer came back up, revert with SequencerGracePeriodNotOver(...). This gives feeds time to catch up after a restart.

A non-zero sequencer feed requires a grace period of at least 3600 seconds. A zero feed with a non-zero grace is rejected.

Validation order & errors

On every live quote, each feed read is validated in this order, with a specific custom error for each failure:

CheckReverts with
answer <= 0InvalidPrice
updatedAt == 0 (round not complete)RoundNotComplete
answeredInRound < roundIdStaleRound
now - updatedAt > maxStalenessStalePrice
asset has no feed/oracleUnsupportedAsset

The ComposedPriceOracle tags these errors with the offending feed address, since two feeds are in play. The OracleDispatcher reverts UnsupportedAsset if an asset has no oracle mapped.

The testnet oracle: ManualPriceOracle (historical)

This section describes the Base Sepolia testnet mechanism; it is not used on Base mainnet, which uses the direct Chainlink stack above. Base Sepolia has no verified Chainlink feeds, so the testnet deployment used an owner-maintained ManualPriceOracle: minOut = usdcIn * rate / 1e18, with a per-asset rate set by the owner. Setting a rate was owner-gated, so changing it cost the same two-day timelock delay as any other configuration change.

Only WETH was rated there, and the floor was deliberately loose. The testnet WETH/USDC pool is thin and badly mispriced, quoting around 210 USDC per WETH, so a floor derived from a real ETH price would reject every swap. The rate was derived from the pool instead, giving an implied ceiling of roughly 769 USDC per WETH.

That had a consequence worth naming: because the floor scales linearly while pool slippage does not, it was generous at small sizes and only bound at larger ones. Measured margin was 3.66x at 5 USDC and 2.25x at 25 USDC, breaching somewhere around 110 to 120 USDC, which is why testnet batches were kept at or below 25 USDC. None of this describes production pricing. It was an artifact of testing against a pool nobody arbitrages.

Base mainnet uses the direct Chainlink stack for WETH and cbBTC. The composed stack remains available only for a separately reviewed future asset addition.

Defense in depth: off-chain divergence monitor

Beyond the on-chain floor, an off-chain monitor (scripts/monitor-oracle-divergence.js) compares the oracle's implied USD price against an independent market source (GeckoTerminal) on a short cron and alerts an operator if they diverge - a tripwire for a mispriced or wrong feed. This is strictly an alert; it is never wired in as an on-chain price floor.