Batch транзакции через Multicall
Как объединить несколько вызовов контрактов в одну Ethereum-транзакцию через Multicall и ethers v6.
Что делает Multicall
Multicall — это контракт, который принимает список вызовов и выполняет их внутри одной транзакции.
Если один вызов падает, весь batch можно откатить. Это удобно, когда несколько действий должны выполниться атомарно: либо все, либо ни одно.
Пишем локальный Multicall
Создайте файл contracts/BatchMulticall.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
uint256 public value;
address public lastCaller;
event Incremented(address indexed caller, uint256 newValue);
function increment() external {
value += 1;
lastCaller = msg.sender;
emit Incremented(msg.sender, value);
}
}
contract BatchMulticall {
struct Call {
address target;
uint256 value;
bytes data;
}
event CallSucceeded(uint256 indexed index, address indexed target, bytes result);
error InvalidMsgValue(uint256 expected, uint256 actual);
error CallFailed(uint256 index, address target, bytes result);
function multicall(Call[] calldata calls) external payable returns (bytes[] memory results) {
results = new bytes[](calls.length);
uint256 totalValue;
for (uint256 i = 0; i < calls.length; i++) {
totalValue += calls[i].value;
}
if (totalValue != msg.value) {
revert InvalidMsgValue(totalValue, msg.value);
}
for (uint256 i = 0; i < calls.length; i++) {
(bool success, bytes memory result) = calls[i].target.call{
value: calls[i].value
}(calls[i].data);
if (!success) {
revert CallFailed(i, calls[i].target, result);
}
results[i] = result;
emit CallSucceeded(i, calls[i].target, result);
}
}
}Контракт BatchMulticall выполняет несколько низкоуровневых call подряд. В примере он вызывает два разных Counter.
Отправляем batch через ethers v6
Создайте файл scripts/batch-multicall.ts:
import { network } from "hardhat";
const { ethers } = await network.create();
const counterA = await ethers.deployContract("Counter");
await counterA.waitForDeployment();
const counterB = await ethers.deployContract("Counter");
await counterB.waitForDeployment();
const multicall = await ethers.deployContract("BatchMulticall");
await multicall.waitForDeployment();
const counterAAddress = await counterA.getAddress();
const counterBAddress = await counterB.getAddress();
const multicallAddress = await multicall.getAddress();
console.log("Counter A:", counterAAddress);
console.log("Counter B:", counterBAddress);
console.log("Multicall:", multicallAddress);
const calls = [
{
target: counterAAddress,
value: 0n,
data: counterA.interface.encodeFunctionData("increment"),
},
{
target: counterBAddress,
value: 0n,
data: counterB.interface.encodeFunctionData("increment"),
},
];
const tx = await multicall.multicall(calls);
console.log("Batch tx:", tx.hash);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) {
throw new Error("Batch-транзакция не была успешно выполнена");
}
console.log("Block:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Counter A value:", (await counterA.value()).toString());
console.log("Counter B value:", (await counterB.value()).toString());
console.log("Counter A last caller:", await counterA.lastCaller());
console.log("Counter B last caller:", await counterB.lastCaller());Запустите локальную сеть:
npx hardhat nodeВо втором терминале выполните скрипт:
npx hardhat run scripts/batch-multicall.ts --network localhostВ результате два контракта Counter увеличатся одной транзакцией.
Как это работает
counterA.interface.encodeFunctionData("increment") — превращает вызов increment() в calldata.
calls — массив вызовов: адрес контракта, ETH value и calldata.
multicall.multicall(calls) — отправляет одну транзакцию в BatchMulticall.
target.call{value: calls[i].value}(calls[i].data) — выполняет конкретный вызов внутри batch.
revert CallFailed(...) — откатывает весь batch, если один вызов завершился ошибкой.
lastCaller — показывает важную деталь: для Counter отправителем будет BatchMulticall, а не EOA, который подписал транзакцию.
Почему это одна транзакция
Без Multicall вы отправили бы две транзакции:
EOA → Counter A.increment()
EOA → Counter B.increment()С Multicall EOA отправляет одну транзакцию:
EOA → BatchMulticall.multicall()
├─ Counter A.increment()
└─ Counter B.increment()Во внешней сети будет один transaction hash, один nonce и один receipt. Внутренние вызовы видны как execution trace и события, но не как отдельные транзакции.
Главное ограничение
Целевые контракты видят msg.sender как адрес Multicall-контракта.
Это значит, что batch не подойдёт для функций, где контракт ожидает именно пользователя:
require(msg.sender == owner);Если вызвать такую функцию через Multicall, msg.sender будет не EOA, а BatchMulticall. Для пользовательских действий часто нужен контракт, специально спроектированный под batch, permit-подписи или собственная функция multicall() внутри протокола.
Multicall3 и read-only batch
Публичный Multicall3 часто используют для batched reads: прочитать балансы, allowance, цены и параметры пулов одним RPC-запросом.
Но read-only batch и batch transaction — разные вещи:
| Сценарий | Что происходит |
|---|---|
eth_call в Multicall3 | чтение без изменения State |
multicall() транзакцией | запись в State через один transaction hash |
Для чтения безопасно использовать публичный Multicall3. Для записи сначала проверьте, кто будет msg.sender внутри целевых контрактов.
Частые ошибки
Думать, что Multicall сохраняет msg.sender пользователя → целевой контракт видит адрес Multicall-контракта.
Ожидать несколько transaction hash → batch создаёт один внешний hash и один receipt.
Игнорировать откат одного вызова → если batch сделан атомарным, ошибка одного call откатит все предыдущие call в этой же транзакции.
Что дальше
- Статусы транзакции — pending, confirmed, failed — проверить receipt batch-транзакции
- Оценка газа — estimateGas — оценить gas для сложного batch перед отправкой
- Работа с nonce вручную — понять, почему batch использует один nonce вместо очереди транзакций