Multicall паттерн
Как добавить self-multicall в Solidity-контракт: принять массив calldata, выполнить delegatecall и объединить несколько действий в одну транзакцию.
Multicall объединяет несколько вызовов
Multicall — это паттерн, где контракт принимает массив encoded calldata и выполняет несколько действий внутри одной транзакции.
В Solidity-паттерне self-multicall вызовы идут не в чужие контракты, а в сам контракт через delegatecall. Поэтому функции работают с тем же storage и видят исходного msg.sender.
Пишем контракт с self-multicall
Создайте файл contracts/TodoBoard.sol:
// 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:
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 этого контракта.
Внешний 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-multicall → msg.sender станет адресом контракта, а не пользователя.
Забыть, что batch атомарный → ошибка одного под-вызова откатывает всю транзакцию.
Доверять multicall вместо проверок доступа → каждая функция всё равно должна проверять owner, роль, лимит и состояние.
Что дальше
- Batch транзакции через Multicall — сравните self-multicall с внешним batch-контрактом
- Модификаторы — modifiers — защищайте функции внутри multicall через access modifiers
- Checks-Effects-Interactions паттерн — держите безопасный порядок в функциях, которые вызываются batch-ом