китаев.tech

Batch транзакции через Multicall

Как объединить несколько вызовов контрактов в одну Ethereum-транзакцию через Multicall и ethers v6.

Что делает Multicall

Multicall — это контракт, который принимает список вызовов и выполняет их внутри одной транзакции.

Если один вызов падает, весь batch можно откатить. Это удобно, когда несколько действий должны выполниться атомарно: либо все, либо ни одно.


Пишем локальный Multicall

Создайте файл contracts/BatchMulticall.sol:

solidity
// 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:

typescript
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());

Запустите локальную сеть:

bash
npx hardhat node

Во втором терминале выполните скрипт:

bash
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 вы отправили бы две транзакции:

text
EOA → Counter A.increment()
EOA → Counter B.increment()

С Multicall EOA отправляет одну транзакцию:

text
EOA → BatchMulticall.multicall()
          ├─ Counter A.increment()
          └─ Counter B.increment()

Во внешней сети будет один transaction hash, один nonce и один receipt. Внутренние вызовы видны как execution trace и события, но не как отдельные транзакции.


Главное ограничение

Целевые контракты видят msg.sender как адрес Multicall-контракта.

Это значит, что batch не подойдёт для функций, где контракт ожидает именно пользователя:

solidity
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 в этой же транзакции.


Что дальше

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