Multicall: The Key to Gas Optimization
A multicall contract 📞 is a smart contract that accepts multiple function calls as inputs and executes them together. A developer can use the multicall contract as a proxy to call functions in other contracts.
A proxy, in this case, refers to the multicall contract itself. It acts as an intermediary between the user and the target contracts they want to interact with.
How Multicall Contracts Work 🛠️
- Single Transaction Efficiency: Instead of initiating individual calls to different contracts, users submit a single transaction to the multicall contract, which contains details about the functions to be executed.
- Batched Execution and Aggregation: The multicall contract then efficiently executes these functions on the respective target contracts and aggregates the results into a unified response.
Benefits of Multicall Contracts💡
- Gas Cost Reduction: Bundling multiple function calls into a single transaction significantly reduces gas costs, enhancing cost-effectiveness for users.
- Network Optimization: Multicall contracts minimize network overhead by consolidating multiple requests into one, leading to improved efficiency and scalability.
- Data Consistency: By executing all functions within a single transaction, multicall contracts ensure atomicity, thereby maintaining data consistency across involved contracts.
Types of Multicall Contracts: 🔄
There have been reviews and improvements on the multicall contract over the years, below are the three we have at the time of writing this article:
- MultiCall: This contract made use of the
aggregate
function, it executes each call sequentially and returns the block number and an array of return data. - MultiCall2: Adds a
tryAggregate
function that allows handling failed calls without reverting the entire transaction. - MultiCall3: Introduces the
aggregate3
function for aggregating calls with the option to revert if any call fails, similar totryAggregate
but with more control.
Deep Dive
In this article, we will be focusing on the codebase for Multicall3, which encompasses an improved version of Multicall and Multicall2.
You can check out a link to the official Multicall GitHub repository here.
aggregate()
struct Call {
address target;
bytes callData;
}
struct Result {
bool success;
bytes returnData;
}
function aggregate(Call[] calldata calls) public payable returns (uint256 blockNumber, bytes[] memory returnData) {
blockNumber = block.number;
uint256 length = calls.length;
returnData = new bytes[](length);
Call calldata call;
for (uint256 i = 0; i < length;) {
bool success;
call = calls[i];
(success, returnData[i]) = call.target.call(call.callData);
require(success, "Multicall3: call failed");
unchecked { ++i; }
}
}
Code Explanation
blockNumber = block.number;
: Record the current block numberuint256 length = calls.length;
: This line retrieves the length of thecalls
array, which indicates the number of function calls to be aggregated.returnData = new bytes[](length);
: This line initializes a dynamic array calledreturnData
with a length equal to the number of function calls. This array will store the return data from each function call.for (uint256 i = 0; i < length;) { ... }
: This loop iterates over each call in thecalls
array.(success, returnData[i]) = call.target.call(call.callData);
: Inside the loop, this line executes the function call specified by thetarget
andcallData
fields of the currentcall
struct. It captures the success status of the call in thesuccess
variable and the return data in thereturnData
array.require(success, "Multicall3: call failed");
: This line ensures that the function call was successful. If the call fails (i.e.,success
isfalse
), the function reverts with an error message.unchecked { ++i; }
: This line increments the loop counteri
to move to the next call in thecalls
array.
Usage of aggregate()
in BalanceChecker()
In the below contract, we are going to use aggregate
function to retrieve the balances of multiple addresses in a single transaction:
contract BalanceChecker is IStruct {
IMulticall3 public multicall;
address public tokenAddress;
event BalanceChecked(address indexed user, uint256 balance);
constructor(address _multicallAddress, address _tokenAddress) {
multicall = IMulticall3(_multicallAddress);
tokenAddress = _tokenAddress;
}
function getTokenBalances(address[] memory addresses) external returns (uint256[] memory) {
Call[] memory calls = new Call[](addresses.length);
for (uint256 i = 0; i < addresses.length; i++) {
calls[i] = Call(
tokenAddress,
abi.encodeWithSignature("balanceOf(address)", addresses[i])
);
}
(, bytes[] memory returnData) = multicall.aggregate(calls);
uint256[] memory balances = new uint256[](returnData.length);
for (uint256 i = 0; i < returnData.length; i++) {
balances[i] = abi.decode(returnData[i], (uint256));
emit BalanceChecked(addresses[i], balances[i]);
}
return balances;
}
}
getTokenBalances()
:
- This function takes an array of
addresses
as input parameters. These addresses represent the users for whom the token balances will be retrieved. - Inside the function, a dynamic array of
Call
structs namedcalls
is created. EachCall
struct contains information about the function call to be executed. In this case, it's thebalanceOf
function of the ERC20 token contract for each user address. - A loop iterates over each address in the
addresses
array.
For each address, aCall
struct is created and added to thecalls
array. Theabi.encodeWithSignature
function is used to encode the function call data for thebalanceOf
function. - After all the function calls are prepared, the
aggregate
function is called with the array ofcalls
. This function aggregates the results of all the function calls into a single transaction. - The
aggregate
function returns two values: the block number (which is ignored in this case) and an array ofbytes
containing the return data from each function call. - Another loop iterates over the
returnData
array, decoding each entry to extract the token balances of the corresponding user address. - For each user’s balance, a
BalanceChecked
event is emitted, containing the user's address and their token balance. - Finally, the function returns an array (
balances
) containing the token balances corresponding to the input addresses.
tryAggregate()
struct Call {
address target;
bytes callData;
}
struct Result {
bool success;
bytes returnData;
}
function tryAggregate(bool requireSuccess, Call[] calldata calls) public payable returns (Result[] memory returnData) {
uint256 length = calls.length;
returnData = new Result[](length);
Call calldata call;
for (uint256 i = 0; i < length;) {
Result memory result = returnData[i];
call = calls[i];
(result.success, result.returnData) = call.target.call(call.callData);
if (requireSuccess) require(result.success, "Multicall3: call failed");
unchecked { ++i; }
}
}
The tryAggregate
function in the Multicall3 contract provides flexibility in handling the success of individual function calls. Here's an explanation of how it works and how it's used in the contract:
- Function Overview: The
tryAggregate
function is designed to aggregate the results of multiple function calls, similar to theaggregate
function. However, it includes an additional parameterrequireSuccess
, which allows callers to specify whether all calls must succeed for the function to proceed. - Input Parameters:
requireSuccess
: A boolean parameter indicating whether all function calls must succeed (true
) or not (false
).calls
: An array ofCall
structs containing the target addresses and call data for each function call.
3. returnData
: An array of Result
structs, each containing the success status and return data of the corresponding function call.
4. Function Execution:
- The function iterates through each call in the
calls
array.
For each call, it executes the call using thetarget
address andcallData
. - If
requireSuccess
istrue
, it checks whether the call was successful. If not, it reverts with an error message.
It then populates thereturnData
array with the success status and returns data for each call.
5. Usage in the Contract:
- In the
Multicall3
contract, thetryAggregate
function is primarily used when callers need to aggregate function calls but don't necessarily require all calls to succeed. - This function provides more flexibility compared to
aggregate
, as it allows callers to handle individual call failures according to their specific requirements.
Usage of tryAggregate()
in BalanceChecker2()
In the below contract, we have also used tryAggregate to
retrieve the balances of multiple addresses:
contract BalanceChecker2 is IStruct {
IMulticall3 public multicall;
address public tokenAddress;
event BalanceChecked(address indexed user, uint256 balance);
constructor(address _multicallAddress, address _tokenAddress) {
multicall = IMulticall3(_multicallAddress);
tokenAddress = _tokenAddress;
}
function getTokenBalancesWithTryAggregate(
address[] memory addresses
) public returns (uint256[] memory) {
Call[] memory calls = new Call[](addresses.length);
for (uint256 i = 0; i < addresses.length; i++) {
calls[i] = Call(
tokenAddress,
abi.encodeWithSignature("balanceOf(address)", addresses[i])
);
}
IMulticall3.Result[] memory results = multicall.tryAggregate(
true,
calls
);
uint256[] memory balances = new uint256[](results.length);
for (uint256 i = 0; i < results.length; i++) {
require(results[i].success, "BalanceChecker2: Call failed");
balances[i] = abi.decode(results[i].returnData, (uint256));
emit BalanceChecked(addresses[i], balances[i]);
}
return balances;
}
}
1. getTokenBalancesWithTryAggregate
Function:
- Users provide an array of wallet addresses (
addresses
) they want to check. - The function returns an array of balances corresponding to the provided addresses.
2. Using tryAggregate
:
- We passed an array of calls and a boolean parameter
true
intotryAggregate
to indicate that all calls must succeed. tryAggregate()
allows handling of individual call failures without reverting the entire transaction.
3. Processing Results:
- After calling
tryAggregate()
, the contract receives an array ofResult
structs. - For each result, the contract checks if the call was successful. If a call fails, it reverts with an error message.
- If the call succeeds, the contract decodes the return data to extract the wallet balance.
aggregate3()
struct Call3 {
address target;
bool allowFailure;
bytes callData;
}
struct Call3Value {
address target;
bool allowFailure;
uint256 value;
bytes callData;
}
struct Result {
bool success;
bytes returnData;
}
function aggregate3(Call3[] calldata calls) public payable returns (Result[] memory returnData) {
uint256 length = calls.length;
returnData = new Result[](length);
Call3 calldata calli;
for (uint256 i = 0; i < length;) {
Result memory result = returnData[i];
calli = calls[i];
(result.success, result.returnData) = calli.target.call(calli.callData);
assembly {
if iszero(or(calldataload(add(calli, 0x20)), mload(result))) {
mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(0x04, 0x0000000000000000000000000000000000000000000000000000000000000020)
mstore(0x24, 0x0000000000000000000000000000000000000000000000000000000000000017)
mstore(0x44, 0x4d756c746963616c6c333a2063616c6c206661696c6564000000000000000000)
revert(0x00, 0x64)
}
}
unchecked { ++i; }
}
}
The aggregate3
function in the Multicall3 contract is designed to aggregate multiple function calls while ensuring that each call returns success. This function is considered both efficient and safe because it handles call failures appropriately and reverts the entire transaction if any call fails when failure is not allowed.
- The
aggregate3
function takes an array ofCall3
structs as input, and it's payable since it might need to transfer ether to some of the called contracts. It returns an array ofResult
structs. - The function initializes some local variables:
length
: This variable stores the length of the input arraycalls
, representing the number of function calls to be aggregated.returnData
: This array will store the results of each function call. It's initialized with a size equal to the length of the input arraycalls
.
3. The function enters a for
loop that iterates over each call in the input array calls
. The loop variable i
is used to index the array.
4. Inside the loop, the function retrieves the i
-th call from the input array and assigns it to the calli
variable. Then, it invokes the call
function on the target address with the provided call data (calli.target.call(calli.callData)
).
5. The result of the function call is stored in a Result
struct. The success
field indicates whether the call was successful, and the returnData
field contains the return value or error message.
6. After each function call, the function checks whether the call was successful. If the call fails and failure is not allowed, the function reverts the entire transaction using assembly code. It constructs an error message and reverts with it.
7. Finally, the loop variable i
is incremented using unchecked { ++i; }
, and the loop continues until all function calls have been processed.
This aggregate3
function ensures that all function calls are executed, and if any call fails unexpectedly, it reverts the entire transaction to maintain the integrity of the contract's state. It's considered safe because it handles call failures appropriately and prevents partial execution.
Usage of aggregate3()
in BalanceChecker3()
contract BalanceChecker3 is IStruct {
IMulticall3 public multicall;
address public tokenAddress;
event BalanceChecked(address indexed user, uint256 balance);
constructor(address _multicallAddress, address _tokenAddress) {
multicall = IMulticall3(_multicallAddress);
tokenAddress = _tokenAddress;
}
function getTokenBalancesWithAggregate3(
address[] memory addresses
) public returns (uint256[] memory) {
IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](
addresses.length
);
for (uint256 i = 0; i < addresses.length; i++) {
calls[i] = Call3(
tokenAddress,
true,
abi.encodeWithSignature("balanceOf(address)", addresses[i])
);
}
IMulticall3.Result[] memory results = multicall.aggregate3(calls);
uint256[] memory balances = new uint256[](results.length);
for (uint256 i = 0; i < results.length; i++) {
require(results[i].success, "BalanceChecker3: Call failed");
balances[i] = abi.decode(results[i].returnData, (uint256));
emit BalanceChecked(addresses[i], balances[i]);
}
return balances;
}
}
getTokenBalancesWithAggregate3
Function: Users provide an array of wallet addresses they want to check.
The function returns an array of balances corresponding to the provided addresses.
2. Preparing Calls:
- Inside
getTokenBalancesWithAggregate3
, the contract prepares an array ofCall3
structs. - Each
Call3
struct represents a call to thebalanceOf
function of the ERC20 token contract for a specific wallet address. - The
allowFailure
field is set totrue
, allowing individual calls to fail without reverting the entire transaction.
3. Aggregate3 Call: The contract invokes the aggregate3()
function from the Multicall
contract, passing the array of Call3
structs.
4. Processing Results:
- After calling
aggregate3()
, the contract receives an array ofResult
structs (results
), each representing the outcome of a call. - For each result, the contract checks if the call was successful. If a call fails, it reverts with an error message.
- If the call succeeds, the contract decodes the return data to extract the wallet balance.
In summary, BalanceChecker3
uses the aggregate3()
function to retrieve balances for multiple wallet addresses efficiently. It ensures that all calls succeed and handles individual call failures gracefully.
Deeper Into Multicall
contract DoubleCallBalanceChecker {
IMulticall3 public multicall;
address public vickishTKN;
address public seyiTKN;
event TokenBalanceChecked(
address indexed user,
uint256 indexed balanceVickishTKN,
uint256 indexed balanceSeyiTKN
);
constructor(address _multicallAddress, address _vickishTKN, address _seyiTKN) {
multicall = IMulticall3(_multicallAddress);
vickishTKN = _vickishTKN;
seyiTKN = _seyiTKN;
}
function getTokenBalances(address[] calldata users) external returns (uint256[][] memory) {
IMulticall3.Call[] memory callsA = new IMulticall3.Call[](users.length);
IMulticall3.Call[] memory callsB = new IMulticall3.Call[](users.length);
for (uint256 i = 0; i < users.length; i++) {
callsA[i] = IMulticall3.Call(
vickishTKN,
abi.encodeWithSignature("balanceOf(address)", users[i])
);
callsB[i] = IMulticall3.Call(
seyiTKN,
abi.encodeWithSignature("balanceOf(address)", users[i])
);
}
IMulticall3.Result[] memory resultsA = multicall.tryAggregate(true, callsA);
IMulticall3.Result[] memory resultsB = multicall.tryAggregate(true, callsB);
// Extract the balances from the results
uint256[][] memory balances = new uint256[][](2);
balances[0] = new uint256[](users.length);
balances[1] = new uint256[](users.length);
for (uint256 i = 0; i < users.length; i++) {
require(resultsA[i].success && resultsB[i].success, "DoubleCallBalanceChecker: Call failed");
balances[0][i] = abi.decode(resultsA[i].returnData, (uint256));
balances[1][i] = abi.decode(resultsB[i].returnData, (uint256));
emit TokenBalanceChecked(users[i], balances[0][i], balances[1][i]);
}
return balances;
}
}
Get Token Balances Function:
The getTokenBalances
function serves as the core functionality of the contract, enabling users to retrieve token balances for multiple addresses efficiently. Here’s how it works:
- Users provide an array of addresses (
users
) for which they want to retrieve token balances. - The function prepares two arrays of calls, one for each token contract, by encoding the
balanceOf
function calls for each user. - It executes
tryAggregate
twice, once for each token contract, to aggregate the function calls into a single transaction. - The function extracts the balance results from the returned data of the
tryAggregate
calls and populates a 2D array (balances
) with the token balances for each user and each token contract. - For each user, the function emits the
TokenBalanceChecked
event, providing the user’s address along with their token balances forvickishTKN
andseyiTKN
. - Finally, the function returns the balances array, containing the token balances for all users across both token contracts.
In the above DoubleCallBalanceChecker
contract, we are making two separate calls to the tryAggregate
function, one for each token contract. Each call aggregates the function calls for its respective token contract.
While calling tryAggregate
twice might seem counterintuitive at first glance, it still results in gas savings compared to making individual calls to each token contract separately. This is because aggregating multiple function calls into a single transaction generally reduces gas costs, even if we make multiple calls to the tryAggregate
function. Here’s why it can still save gas:
- Reduced Overhead: Aggregating multiple calls into one transaction reduces the overhead associated with sending multiple transactions. This includes the base cost of sending a transaction and the overhead of accessing the Ethereum network.
- Batching Efficiency: Although we are making two separate calls to
tryAggregate
, each call still aggregates multiple function calls into one transaction. This batching of function calls within eachtryAggregate
call can lead to gas savings compared to making individual calls for each user and each token contract separately.
Conclusion
The Multicall contract provides an efficient solution for aggregating multiple function calls in Ethereum smart contracts. By allowing batch processing of function calls, these contracts reduce gas costs, minimize blockchain congestion, and optimize on-chain data retrieval. 📉
Whether it’s retrieving token balances, querying blockchain data, or executing complex logic involving multiple contract interactions, Multicall contracts offer a streamlined approach that significantly improves the efficiency and cost-effectiveness of smart contract operations. 🎯
As the blockchain continues to evolve and scale, tools and techniques like Multicall contracts will play an increasingly vital role in optimizing blockchain applications, making them more scalable, affordable, and user-friendly for developers and end-users alike. 🌐
Additional Resources 📚
- Multicall Official Repository — Official GitHub repository for the Multicall contract to explore its source code and documentation.
- Multicall Website — Multicall website.
- Sample Contract GitHub Repository — GitHub repository containing the sample contracts discussed in this article for hands-on learning and experimentation.
Thanks for reading!
📖 I hope this article has provided valuable insights into the power and efficiency of Multicall contracts in Ethereum smart contract development. 🛠️ If you have any questions or want to continue the conversation, feel free to connect with me on my socials:
Let’s stay connected and explore more about the blockchain, smart contracts, and decentralized applications together! 🌐