How to Build a DeFi Price Feed Using RPC: eth_call Patterns
Every DeFi application needs prices. Token prices, LP prices, oracle prices: all need to be accurate, fast, and resilient when one source goes stale or reverts.
This guide covers how to read prices directly from on-chain sources via eth_call: Uniswap V3 pools, Chainlink price feeds, and Curve pools. Plus how to aggregate multiple sources and cache efficiently.
Why Read Prices via RPC?
Off-chain price APIs (CoinGecko, CoinMarketCap) introduce dependencies your application should not have. On-chain prices via eth_call are:
- Trustless: you read directly from the same contracts your users interact with
- Real-time: prices update every block, not on an API schedule
- Reliable: no API key to manage, no rate limits from a third party
- Accurate: the exact price the blockchain sees, not an aggregated estimate
The trade-off: more RPC calls, more application complexity. The patterns in this guide minimize both.
Reading Uniswap V3 Prices
Uniswap V3 stores prices as sqrtPriceX96, a square root price in Q64.96 fixed-point format. Converting to a human-readable price requires math.
import { ethers } from 'ethers';
const UNISWAP_V3_POOL_ABI = [
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function fee() view returns (uint24)',
];
const ERC20_ABI = [
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
];
const provider = new ethers.JsonRpcProvider(
'https://eu.endpoints.matrixed.link/rpc/ethereum?auth=YOUR_KEY'
);
async function getUniswapV3Price(poolAddress) {
const pool = new ethers.Contract(poolAddress, UNISWAP_V3_POOL_ABI, provider);
// Batch pool data reads with Multicall3
const [slot0, token0Address, token1Address] = await Promise.all([
pool.slot0(),
pool.token0(),
pool.token1(),
]);
// Get token decimals
const token0 = new ethers.Contract(token0Address, ERC20_ABI, provider);
const token1 = new ethers.Contract(token1Address, ERC20_ABI, provider);
const [decimals0, decimals1] = await Promise.all([
token0.decimals(),
token1.decimals(),
]);
// Convert sqrtPriceX96 to price
const sqrtPrice = slot0.sqrtPriceX96;
const price = convertSqrtPriceX96(sqrtPrice, decimals0, decimals1);
return {
price, // token1 per token0
tick: slot0.tick,
token0: token0Address,
token1: token1Address,
};
}
function convertSqrtPriceX96(sqrtPriceX96, decimals0, decimals1) {
// Price = (sqrtPriceX96 / 2^96)^2 * 10^decimals0 / 10^decimals1
const Q96 = BigInt(2) ** BigInt(96);
const price = (sqrtPriceX96 * sqrtPriceX96 * BigInt(10 ** decimals0)) /
(Q96 * Q96 * BigInt(10 ** decimals1));
return Number(price) / 1e18;
}
// ETH/USDC pool example
const WETH_USDC_POOL = '0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640';
const { price } = await getUniswapV3Price(WETH_USDC_POOL);
console.log(`ETH price: $${price.toFixed(2)}`);
Reading Chainlink Price Feeds
Chainlink aggregators expose a clean interface, with no complex math required.
const CHAINLINK_ABI = [
'function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)',
'function decimals() view returns (uint8)',
'function description() view returns (string)',
];
// Chainlink ETH/USD feed on Ethereum mainnet
const ETH_USD_FEED = '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419';
async function getChainlinkPrice(feedAddress) {
const feed = new ethers.Contract(feedAddress, CHAINLINK_ABI, provider);
const [roundData, decimals] = await Promise.all([
feed.latestRoundData(),
feed.decimals(),
]);
// Check freshness — reject stale data
const age = Math.floor(Date.now() / 1000) - Number(roundData.updatedAt);
if (age > 3600) {
throw new Error(`Stale price data: last updated ${age}s ago`);
}
const price = Number(roundData.answer) / 10 ** decimals;
return {
price,
roundId: roundData.roundId,
updatedAt: roundData.updatedAt,
ageSeconds: age,
};
}
const ethPrice = await getChainlinkPrice(ETH_USD_FEED);
console.log(`ETH/USD: $${ethPrice.price} (${ethPrice.ageSeconds}s old)`);
Common Chainlink feeds on Ethereum mainnet:
| Pair | Address |
|---|---|
| ETH/USD | 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 |
| BTC/USD | 0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88b |
| LINK/USD | 0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c |
| USDC/USD | 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6 |
Reading Curve Pool Prices
Curve pools expose prices via get_dy (how much you get out) or price_oracle for newer pools.
const CURVE_POOL_ABI = [
'function get_dy(int128 i, int128 j, uint256 dx) view returns (uint256)',
'function price_oracle() view returns (uint256)', // For newer Curve V2 pools
'function coins(uint256 i) view returns (address)',
];
async function getCurvePrice(poolAddress, tokenInIndex, tokenOutIndex, decimals) {
const pool = new ethers.Contract(poolAddress, CURVE_POOL_ABI, provider);
// Simulate swapping 1 token to get the price
const amountIn = ethers.parseUnits('1', decimals);
try {
const amountOut = await pool.get_dy(tokenInIndex, tokenOutIndex, amountIn);
return Number(ethers.formatUnits(amountOut, decimals));
} catch {
// Fallback to price_oracle for V2 pools
const oraclePrice = await pool.price_oracle();
return Number(ethers.formatUnits(oraclePrice, 18));
}
}
Multi-Source Price Aggregation
Never rely on a single price source in production. Aggregate multiple sources and use median or weighted average to reduce manipulation risk.
class PriceAggregator {
constructor(sources) {
this.sources = sources; // [{ name, fetchFn, weight }]
}
async getPrice(asset) {
const results = await Promise.allSettled(
this.sources.map(async source => ({
name: source.name,
price: await source.fetchFn(asset),
weight: source.weight,
}))
);
const valid = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
if (valid.length === 0) {
throw new Error(`No valid price sources for ${asset}`);
}
if (valid.length === 1) {
console.warn(`Only one price source available for ${asset}`);
return valid[0].price;
}
// Check for outliers (>5% deviation from median)
const prices = valid.map(v => v.price).sort((a, b) => a - b);
const median = prices[Math.floor(prices.length / 2)];
const filtered = valid.filter(v => {
const deviation = Math.abs(v.price - median) / median;
if (deviation > 0.05) {
console.warn(`Outlier detected from ${v.name}: ${v.price} (${(deviation * 100).toFixed(1)}% from median)`);
return false;
}
return true;
});
// Weighted average of non-outlier sources
const totalWeight = filtered.reduce((sum, v) => sum + v.weight, 0);
const weightedPrice = filtered.reduce((sum, v) => sum + v.price * v.weight, 0) / totalWeight;
return weightedPrice;
}
}
// Configure sources for ETH/USD
const ethPriceAggregator = new PriceAggregator([
{
name: 'Chainlink',
fetchFn: () => getChainlinkPrice(ETH_USD_FEED).then(r => r.price),
weight: 3, // Highest trust — oracle designed for this
},
{
name: 'Uniswap V3',
fetchFn: () => getUniswapV3Price(WETH_USDC_POOL).then(r => r.price),
weight: 2,
},
]);
const ethPrice = await ethPriceAggregator.getPrice('ETH');
console.log(`Aggregated ETH price: $${ethPrice.toFixed(2)}`);
Caching Strategy
Prices change every block (~12 seconds on Ethereum). Cache within a block, not across blocks.
class BlockAwarePriceCache {
constructor(provider) {
this.provider = provider;
this.cache = new Map();
this.currentBlock = null;
// Invalidate cache on every new block
provider.on('block', (blockNumber) => {
if (blockNumber > this.currentBlock) {
this.cache.clear();
this.currentBlock = blockNumber;
}
});
}
async getPrice(key, fetchFn) {
if (this.cache.has(key)) {
return this.cache.get(key);
}
const price = await fetchFn();
this.cache.set(key, price);
return price;
}
}
const priceCache = new BlockAwarePriceCache(wsProvider);
// Multiple components requesting ETH price in the same block
// → only one RPC call is made, rest served from cache
async function getEthPrice() {
return priceCache.getPrice('ETH/USD', () =>
getChainlinkPrice(ETH_USD_FEED).then(r => r.price)
);
}
Batch Multiple Prices with Multicall3
If you need prices for many assets simultaneously, batch all reads into one RPC call:
const MULTICALL3_ABI = [
'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) view returns (tuple(bool success, bytes returnData)[] returnData)',
];
const chainlinkInterface = new ethers.Interface(CHAINLINK_ABI);
async function getBatchPrices(feeds) {
const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider);
const calls = feeds.flatMap(feed => [
{
target: feed.address,
allowFailure: true,
callData: chainlinkInterface.encodeFunctionData('latestRoundData'),
},
{
target: feed.address,
allowFailure: true,
callData: chainlinkInterface.encodeFunctionData('decimals'),
},
]);
// All feeds in one RPC call
const results = await multicall.aggregate3(calls);
return feeds.map((feed, i) => {
const roundResult = results[i * 2];
const decimalsResult = results[i * 2 + 1];
if (!roundResult.success || !decimalsResult.success) {
return { symbol: feed.symbol, price: null, error: true };
}
const roundData = chainlinkInterface.decodeFunctionResult('latestRoundData', roundResult.returnData);
const decimals = chainlinkInterface.decodeFunctionResult('decimals', decimalsResult.returnData)[0];
return {
symbol: feed.symbol,
price: Number(roundData.answer) / 10 ** decimals,
updatedAt: roundData.updatedAt,
};
});
}
// Get 10 prices in 1 RPC call
const prices = await getBatchPrices([
{ symbol: 'ETH', address: ETH_USD_FEED },
{ symbol: 'BTC', address: BTC_USD_FEED },
{ symbol: 'LINK', address: LINK_USD_FEED },
// ... more feeds
]);
FAQ
Should I use Chainlink or Uniswap prices?
It depends on the use case. Chainlink oracles are designed for protocol use. They are harder to manipulate, regularly updated, and have circuit breakers. Use Chainlink for anything that involves on-chain logic (collateral calculations, liquidation thresholds). Use Uniswap for real-time display prices where manipulation resistance matters less than freshness.
How do I get prices for long-tail tokens without Chainlink feeds?
For tokens without Chainlink feeds, use Uniswap V3 TWAP (time-weighted average price) instead of spot price. It is harder to manipulate over a 30-minute window. Alternatively, find the highest-liquidity Uniswap pool for the token and read the spot price, but validate it against known anchor prices.
Why is my Uniswap price inverted?
Uniswap V3 always expresses price as token1/token0 where token0 < token1 (lexicographic sort by address). If USDC address is lower than WETH address, the price is expressed as WETH per USDC (a very small number), not USDC per WETH. Invert the calculation if token0 is your quote currency.
How do I handle price feed outages?
Always have at least two sources. If Chainlink has an incident, fall back to Uniswap TWAP. If both are unavailable, use a cached price with a staleness warning rather than reverting entirely, and let your application decide how to handle stale data.
What is the minimum update frequency I should use?
Price staleness depends on asset volatility. For major assets (ETH, BTC): one block (12 seconds) is safe. For stablecoins: 5-10 minutes is usually acceptable. For volatile long-tail assets: one block, and add slippage tolerance since the price can move significantly between your read and the user’s transaction.
BoltRPC provides the HTTP and WebSocket RPC access your price feed needs: eth_call for Multicall3 batch reads, WebSocket for block subscriptions, archive access for TWAP calculations.
Start your free 2-week trial: trial.boltrpc.io
Related: Optimizing RPC Calls for DeFi | Ethereum RPC Methods Guide