Multicall3 — батчинг вызовов
Как объединить несколько read-only вызовов DeFi-контрактов в один eth_call через Multicall3 и декодировать результаты в ethers v6.
Multicall3 батчит чтение
Multicall3 — это публичный контракт, который выполняет много view-вызовов внутри одного eth_call.
Для DeFi-интерфейса это удобный способ одновременно прочитать цены, балансы, allowance, параметры пулов и состояние lending-позиции без десятков отдельных RPC-запросов.
Читаем несколько DeFi-значений
Пример за один вызов читает:
- цену ETH/USD из Chainlink
decimalsу Chainlink feed- WETH баланс Aave V3 Pool
- USDC баланс Aave V3 Pool
Создайте проект:
mkdir multicall3-example
cd multicall3-example
npm init -y
npm pkg set type="module"
npm install ethers tsx
mkdir srcСоздайте файл src/multicall3.ts:
import { ethers } from "ethers";
const RPC_URL = "https://ethereum-rpc.publicnode.com";
const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
const AAVE_V3_POOL_ADDRESS = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2";
const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const ETH_USD_FEED = "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419";
const WETH_DECIMALS = 18;
const USDC_DECIMALS = 6;
const MULTICALL3_ABI = [
"function aggregate3((address target,bool allowFailure,bytes callData)[] calls) payable returns ((bool success,bytes returnData)[] returnData)",
];
const ERC20_ABI = [
"function balanceOf(address account) external view returns (uint256)",
];
const FEED_ABI = [
"function decimals() external view returns (uint8)",
"function latestRoundData() external view returns (uint80 roundId,int256 answer,uint256 startedAt,uint256 updatedAt,uint80 answeredInRound)",
];
type BatchedCall = {
label: string;
target: string;
allowFailure: boolean;
iface: ethers.Interface;
functionName: string;
args?: readonly unknown[];
};
const provider = new ethers.JsonRpcProvider(RPC_URL);
const multicall = new ethers.Contract(
MULTICALL3_ADDRESS,
MULTICALL3_ABI,
provider,
);
const erc20Interface = new ethers.Interface(ERC20_ABI);
const feedInterface = new ethers.Interface(FEED_ABI);
const batchedCalls: BatchedCall[] = [
{
label: "ethUsdRound",
target: ETH_USD_FEED,
allowFailure: false,
iface: feedInterface,
functionName: "latestRoundData",
},
{
label: "ethUsdDecimals",
target: ETH_USD_FEED,
allowFailure: false,
iface: feedInterface,
functionName: "decimals",
},
{
label: "aaveWethBalance",
target: WETH_ADDRESS,
allowFailure: false,
iface: erc20Interface,
functionName: "balanceOf",
args: [AAVE_V3_POOL_ADDRESS],
},
{
label: "aaveUsdcBalance",
target: USDC_ADDRESS,
allowFailure: false,
iface: erc20Interface,
functionName: "balanceOf",
args: [AAVE_V3_POOL_ADDRESS],
},
];
const calls = batchedCalls.map((call) => ({
target: call.target,
allowFailure: call.allowFailure,
callData: call.iface.encodeFunctionData(call.functionName, call.args ?? []),
}));
const results = await multicall.aggregate3.staticCall(calls);
const decoded = new Map<string, ethers.Result>();
for (const [index, result] of results.entries()) {
const call = batchedCalls[index];
if (!result.success) {
console.log(`${call.label}: failed`);
continue;
}
decoded.set(
call.label,
call.iface.decodeFunctionResult(call.functionName, result.returnData),
);
}
const ethUsdRound = decoded.get("ethUsdRound");
const ethUsdDecimals = decoded.get("ethUsdDecimals");
const aaveWethBalance = decoded.get("aaveWethBalance");
const aaveUsdcBalance = decoded.get("aaveUsdcBalance");
if (!ethUsdRound || !ethUsdDecimals || !aaveWethBalance || !aaveUsdcBalance) {
throw new Error("Missing required multicall result");
}
const [, answer, , updatedAt] = ethUsdRound;
const [feedDecimals] = ethUsdDecimals;
const [wethBalance] = aaveWethBalance;
const [usdcBalance] = aaveUsdcBalance;
if (answer <= 0n) {
throw new Error("Invalid ETH/USD price");
}
console.log(`ETH/USD: ${ethers.formatUnits(answer, feedDecimals)}`);
console.log(`Updated at: ${new Date(Number(updatedAt) * 1000).toISOString()}`);
console.log(
`Aave Pool WETH: ${ethers.formatUnits(wethBalance, WETH_DECIMALS)}`,
);
console.log(
`Aave Pool USDC: ${ethers.formatUnits(usdcBalance, USDC_DECIMALS)}`,
);Запустите:
npx tsx src/multicall3.tsСкрипт выведет примерно такой результат:
ETH/USD: 3542.18420300
Updated at: 2026-06-20T16:50:47.000Z
Aave Pool WETH: 736842.129411874251338211
Aave Pool USDC: 145902834.291233Все значения прочитаны одним eth_call к Multicall3.
Как это работает
MULTICALL3_ADDRESS — публичный адрес Multicall3. Он одинаковый во многих EVM-сетях, включая Ethereum mainnet.
encodeFunctionData() — превращает обычный вызов функции в calldata.
aggregate3() — принимает массив вызовов: target, allowFailure, callData.
staticCall() — выполняет read-only вызов. State не меняется, транзакция не отправляется, gas с пользователя не списывается.
allowFailure: false — если один вызов упадёт, весь aggregate3 откатится. Для необязательных данных можно поставить true и обработать success: false.
decodeFunctionResult() — превращает raw returnData обратно в значения нужной функции.
Multicall3 не сохраняет msg.sender
Целевой контракт видит msg.sender как адрес Multicall3, а не пользователя.
Для обычных balanceOf, decimals, slot0, latestRoundData это не проблема. Но вызовы, которые зависят от msg.sender, через публичный Multicall3 дадут другой результат:
function myPosition() external view returns (uint256) {
return positions[msg.sender];
}Если вызвать такую функцию через Multicall3, она прочитает positions[Multicall3], а не positions[user].
Частые ошибки
Путать read batching и batch transaction → aggregate3.staticCall() не создаёт transaction hash и не меняет State.
Декодировать результат не тем ABI → returnData нужно декодировать интерфейсом той функции, которую вы кодировали.
Игнорировать success → если allowFailure: true, упавший вызов не откатит весь batch. Проверяйте success перед decode.
Батчить user-dependent view без параметра user → функции с msg.sender вернут данные Multicall3. Лучше использовать функции, где адрес пользователя передаётся аргументом.
Что дальше
- Chainlink Price Feed — читать oracle-цену и проверять freshness
- Deposit в Aave V3 — понять, какие DeFi-состояния удобно читать батчем
- Batch транзакции через Multicall — отличить read-only Multicall3 от атомарной batch-транзакции