китаев.tech

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

Создайте проект:

bash
mkdir multicall3-example
cd multicall3-example

npm init -y
npm pkg set type="module"
npm install ethers tsx
mkdir src

Создайте файл src/multicall3.ts:

typescript
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)}`,
);

Запустите:

bash
npx tsx src/multicall3.ts

Скрипт выведет примерно такой результат:

text
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 дадут другой результат:

solidity
function myPosition() external view returns (uint256) {
    return positions[msg.sender];
}

Если вызвать такую функцию через Multicall3, она прочитает positions[Multicall3], а не positions[user].

Частые ошибки

Путать read batching и batch transactionaggregate3.staticCall() не создаёт transaction hash и не меняет State.

Декодировать результат не тем ABIreturnData нужно декодировать интерфейсом той функции, которую вы кодировали.

Игнорировать success → если allowFailure: true, упавший вызов не откатит весь batch. Проверяйте success перед decode.

Батчить user-dependent view без параметра user → функции с msg.sender вернут данные Multicall3. Лучше использовать функции, где адрес пользователя передаётся аргументом.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.