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 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.
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
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.
<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 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:
- If the sequencer policy was never configured, revert. L1 and local deployments must explicitly disable it with
(address(0), 0). - 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.
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:
| 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.
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.