китаев.tech

Multicall паттерн

Как добавить self-multicall в Solidity-контракт: принять массив calldata, выполнить delegatecall и объединить несколько действий в одну транзакцию.

Multicall объединяет несколько вызовов

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

В Solidity-паттерне self-multicall вызовы идут не в чужие контракты, а в сам контракт через delegatecall. Поэтому функции работают с тем же storage и видят исходного msg.sender.


Пишем контракт с self-multicall

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

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

abstract contract SelfMulticall {
    event MulticallExecuted(address indexed caller, uint256 callsCount);

    error EmptyCalls();
    error MulticallFailed(uint256 index, bytes result);

    function multicall(bytes[] calldata data) external returns (bytes[] memory results) {
        if (data.length == 0) {
            revert EmptyCalls();
        }

        results = new bytes[](data.length);

        for (uint256 i = 0; i < data.length; i++) {
            (bool success, bytes memory result) = address(this).delegatecall(data[i]);

            if (!success) {
                revert MulticallFailed(i, result);
            }

            results[i] = result;
        }

        emit MulticallExecuted(msg.sender, data.length);
    }
}

contract TodoBoard is SelfMulticall {
    struct Task {
        address owner;
        string text;
        bool completed;
    }

    Task[] private tasks;

    event TaskCreated(uint256 indexed taskId, address indexed owner, string text);
    event TaskRenamed(uint256 indexed taskId, string text);
    event TaskCompleted(uint256 indexed taskId);

    error EmptyText();
    error InvalidTask(uint256 taskId);
    error OnlyTaskOwner(uint256 taskId, address caller);
    error AlreadyCompleted(uint256 taskId);

    function createTask(string calldata text) external returns (uint256 taskId) {
        if (bytes(text).length == 0) {
            revert EmptyText();
        }

        taskId = tasks.length;

        tasks.push(
            Task({
                owner: msg.sender,
                text: text,
                completed: false
            })
        );

        emit TaskCreated(taskId, msg.sender, text);
    }

    function renameTask(uint256 taskId, string calldata newText) external {
        if (bytes(newText).length == 0) {
            revert EmptyText();
        }

        Task storage task = _getOwnedTask(taskId);

        task.text = newText;

        emit TaskRenamed(taskId, newText);
    }

    function completeTask(uint256 taskId) external {
        Task storage task = _getOwnedTask(taskId);

        if (task.completed) {
            revert AlreadyCompleted(taskId);
        }

        task.completed = true;

        emit TaskCompleted(taskId);
    }

    function getTask(uint256 taskId) external view returns (Task memory) {
        if (taskId >= tasks.length) {
            revert InvalidTask(taskId);
        }

        return tasks[taskId];
    }

    function taskCount() external view returns (uint256) {
        return tasks.length;
    }

    function _getOwnedTask(uint256 taskId) private view returns (Task storage task) {
        if (taskId >= tasks.length) {
            revert InvalidTask(taskId);
        }

        task = tasks[taskId];

        if (task.owner != msg.sender) {
            revert OnlyTaskOwner(taskId, msg.sender);
        }
    }
}

Этот контракт позволяет пользователю отправить одну транзакцию с несколькими действиями: создать задачу, переименовать её и отметить выполненной.


Как это работает

multicall(bytes[] calldata data) — принимает массив encoded calldata. Каждый элемент — это один вызов функции этого же контракта.

address(this).delegatecall(data[i]) — выполняет calldata в контексте текущего контракта. Storage остаётся тем же, а msg.sender остаётся пользователем, который вызвал multicall.

results = new bytes[](data.length) — собирает raw-результаты всех вызовов. Если функция возвращает значение, оно попадёт в соответствующий results[i].

revert MulticallFailed(i, result) — откатывает весь batch, если один вызов упал. Все изменения предыдущих вызовов в этой транзакции тоже откатятся.

_getOwnedTask(taskId) — обычные проверки доступа продолжают работать. Внутри delegatecall msg.sender не становится адресом контракта.

emit MulticallExecuted(...) — записывает общий факт batch-выполнения. Отдельные функции всё равно эмитят свои события.


Как кодировать вызовы

Frontend кодирует под-вызовы через ABI:

typescript
const calls = [
  todo.interface.encodeFunctionData("createTask", ["Написать тесты"]),
  todo.interface.encodeFunctionData("renameTask", [0, "Написать unit-тесты"]),
  todo.interface.encodeFunctionData("completeTask", [0]),
];

await todo.multicall(calls);

Все три действия попадут в один transaction hash. Если completeTask(0) откатится, создание и переименование задачи тоже откатятся.


Self-multicall vs внешний Multicall

Внешний Multicall вызывает другие контракты через call. Для целевого контракта msg.sender будет адрес Multicall-контракта.

Self-multicall вызывает функции своего же контракта через delegatecall. Поэтому msg.sender остаётся пользователем, а storage остаётся storage этого контракта.

text
Внешний Multicall:
EOA -> Multicall -> Target
Target видит msg.sender = Multicall

Self-multicall:
EOA -> TodoBoard.multicall() -> TodoBoard через delegatecall
TodoBoard видит msg.sender = EOA

Ограничения

Self-multicall даёт пользователю вызвать любую внешнюю функцию контракта, которую он и так мог бы вызвать напрямую. Поэтому доступ всё равно должен проверяться внутри каждой функции.

Будьте осторожны с payable-функциями и msg.value. При delegatecall под-вызовы видят тот же msg.value, из-за чего один и тот же ETH может быть логически учтён несколько раз, если код написан неаккуратно.

Не используйте multicall как замену нормальной бизнес-функции, если порядок действий сложный и важный. Иногда безопаснее написать отдельную функцию createAndComplete(...), чем разрешать произвольный batch.


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

Использовать call вместо delegatecall для self-multicallmsg.sender станет адресом контракта, а не пользователя.

Забыть, что batch атомарный → ошибка одного под-вызова откатывает всю транзакцию.

Доверять multicall вместо проверок доступа → каждая функция всё равно должна проверять owner, роль, лимит и состояние.


Что дальше

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