Price oracle
An independent on-chain price floor that the executor cannot lower - the safety net under every swap.
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 from live Chainlink prices, 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 - there is no silent "no protection" path.
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:
oracleMinOut = priceOracle.quoteMinOut(targetAsset, actualTotal)
adjustedExecutorMinOut = minAmountOut * actualTotal / eligibleTotal // scaled for skips
effectiveMinOut = max(adjustedExecutorMinOut, oracleMinOut)
// swap must return at least effectiveMinOut, else revert
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 used for cbETH and wstETH
The dispatcher only routes. Each underlying oracle runs its own sequencer check, staleness check, and slippage floor - so a fault in the composed path can only affect composed assets (failure-domain isolation, ).
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). Slippage is hard-capped at MAX_SLIPPAGE_BPS = 2000 (20%) - a configuration sanity bound, not the per-batch slippage.
Composed prices - ComposedPriceOracle
cbETH and wstETH have no direct USD feed on Base, but they each have a reliable "how much ETH is this worth" market feed. So the oracle multiplies two feeds: (asset in ETH) * (ETH in USD) = asset in USD.
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.
<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.
| Feed | Measured heartbeat | Staleness window | Deploy parameter |
|---|---|---|---|
| ETH/USD (direct + quote leg) | ~20 min | 3600 s (1 h) | DEPLOY_ORACLE_MAX_STALENESS / ..._QUOTE_STALENESS |
| cbBTC/USD (direct) | ~20 min | 3600 s (1 h) | DEPLOY_ORACLE_MAX_STALENESS |
| cbETH/ETH, wstETH/ETH (composed base) | ~24 h | 90000 s (25 h) | DEPLOY_ORACLE_BASE_STALENESS |
<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 a maxStaleness of 0 is 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:
- If no sequencer feed is configured (L1 or local tests), skip the check.
- Read the Chainlink L2 Sequencer Uptime feed:
answer == 0means up,1means down. - If
answer != 0, revert withSequencerDown(). - If the round's
startedAt == 0(uninitialized), revert withSequencerDown(). - If less than
gracePeriodseconds have passed since the sequencer came back up, revert withSequencerGracePeriodNotOver(...). This gives feeds time to catch up after a restart.
Validation order & errors
On every live quote, each feed read is validated in this order, with a specific custom error for each failure:
| Check | Reverts with |
|---|---|
answer <= 0 | InvalidPrice |
updatedAt == 0 (round not complete) | RoundNotComplete |
answeredInRound < roundId | StaleRound |
now - updatedAt > maxStaleness | StalePrice |
| asset has no feed/oracle | UnsupportedAsset |
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.
Testnet fallback - ManualPriceOracle
Base Sepolia has no verified Chainlink feeds, so testnet uses an owner-maintained ManualPriceOracle: minOut = usdcIn * rate / 1e18, where the owner sets a conservative (low) per-asset rate. On Sepolia only WETH is rated, with a deliberately low floor - the testnet WETH/USDC pool is pathologically thin, so a tight floor would brick swaps. Mainnet uses the Chainlink + composed stack described above; this fallback is testnet/local only.
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.