diff --git a/CHANGELOG.md b/CHANGELOG.md index 0051deb3e56..1ee74687f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * `ERC20Permit`: add a `_useNonce` to enable further usage of ERC712 signatures. ([#2565](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2565)) * `ERC20FlashMint`: add an implementation of the ERC3156 extension for flash-minting ERC20 tokens. ([#2543](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2543)) * `SignatureChecker`: add a signature verification library that supports both EOA and ERC1271 compliant contracts as signers. ([#2532](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2532)) + * `Multicall`: add abstract contract with `multicall(bytes[] calldata data)` function to bundle multiple calls together ([#2608](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2608)) ## 4.0.0 (2021-03-23) diff --git a/contracts/mocks/MulticallTest.sol b/contracts/mocks/MulticallTest.sol new file mode 100644 index 00000000000..4a739371745 --- /dev/null +++ b/contracts/mocks/MulticallTest.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./MulticallTokenMock.sol"; + +contract MulticallTest { + function testReturnValues(MulticallTokenMock multicallToken, address[] calldata recipients, uint256[] calldata amounts) external { + bytes[] memory calls = new bytes[](recipients.length); + for (uint i = 0; i < recipients.length; i++) { + calls[i] = abi.encodeWithSignature("transfer(address,uint256)", recipients[i], amounts[i]); + } + + bytes[] memory results = multicallToken.multicall(calls); + for (uint i = 0; i < results.length; i++) { + require(abi.decode(results[i], (bool))); + } + } +} diff --git a/contracts/mocks/MulticallTokenMock.sol b/contracts/mocks/MulticallTokenMock.sol new file mode 100644 index 00000000000..46870563663 --- /dev/null +++ b/contracts/mocks/MulticallTokenMock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../utils/Multicall.sol"; +import "./ERC20Mock.sol"; + +contract MulticallTokenMock is ERC20Mock, Multicall { + constructor (uint256 initialBalance) ERC20Mock("MulticallToken", "BCT", msg.sender, initialBalance) {} +} diff --git a/contracts/utils/Multicall.sol b/contracts/utils/Multicall.sol new file mode 100644 index 00000000000..2dcb1d7fce7 --- /dev/null +++ b/contracts/utils/Multicall.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./Address.sol"; + +/** + * @dev Provides a function to batch together multiple calls in a single external call. + */ +abstract contract Multicall { + /** + * @dev Receives and executes a batch of function calls on this contract. + */ + function multicall(bytes[] calldata data) external returns (bytes[] memory results) { + results = new bytes[](data.length); + for (uint i = 0; i < data.length; i++) { + results[i] = Address.functionDelegateCall(address(this), data[i]); + } + return results; + } +} diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index cdc429ebd91..d475648090e 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -6,6 +6,7 @@ NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/ Miscellaneous contracts and libraries containing utility functions you can use to improve security, work with new data types, or safely use low-level primitives. The {Address}, {Arrays} and {Strings} libraries provide more operations related to these native data types, while {SafeCast} adds ways to safely convert between the different signed and unsigned numeric types. +{Multicall} provides a function to batch together multiple calls in a single external call. For new data types: @@ -94,3 +95,5 @@ Note that, in all cases, accounts simply _declare_ their interfaces, but they ar {{Counters}} {{Strings}} + +{{Multicall}} diff --git a/docs/modules/ROOT/pages/utilities.adoc b/docs/modules/ROOT/pages/utilities.adoc index ab578ab8225..cdbb09d9371 100644 --- a/docs/modules/ROOT/pages/utilities.adoc +++ b/docs/modules/ROOT/pages/utilities.adoc @@ -99,3 +99,41 @@ Want to check if an address is a contract? Use xref:api:utils.adoc#Address[`Addr Want to keep track of some numbers that increment by 1 every time you want another one? Check out xref:api:utils.adoc#Counters[`Counters`]. This is useful for lots of things, like creating incremental identifiers, as shown on the xref:erc721.adoc[ERC721 guide]. +=== Multicall + +The `Multicall` abstract contract comes with a `multicall` function that bundles together multiple calls in a single external call. With it, external accounts may perform atomic operations comprising several function calls. This is not only useful for EOAs to make multiple calls in a single transaction, it's also a way to revert a previous call if a later one fails. + +Consider this dummy contract: + +[source,solidity] +---- +// contracts/Box.sol +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/Multicall.sol"; + +contract Box is Multicall { + function foo() public { + ... + } + + function bar() public { + ... + } +} +---- + +This is how to call the `multicall` function using Truffle, allowing `foo` and `bar` to be called in a single transaction: +[source,javascript] +---- +// scripts/foobar.js + +const Box = artifacts.require('Box'); +const instance = await Box.new(); + +await instance.multicall([ + instance.contract.methods.foo().encodeABI(), + instance.contract.methods.bar().encodeABI() +]); +---- diff --git a/test/utils/Multicall.test.js b/test/utils/Multicall.test.js new file mode 100644 index 00000000000..c6453bb6122 --- /dev/null +++ b/test/utils/Multicall.test.js @@ -0,0 +1,57 @@ +const { BN, expectRevert } = require('@openzeppelin/test-helpers'); +const MulticallTokenMock = artifacts.require('MulticallTokenMock'); + +contract('MulticallToken', function (accounts) { + const [deployer, alice, bob] = accounts; + const amount = 12000; + + beforeEach(async function () { + this.multicallToken = await MulticallTokenMock.new(new BN(amount), { from: deployer }); + }); + + it('batches function calls', async function () { + expect(await this.multicallToken.balanceOf(alice)).to.be.bignumber.equal(new BN('0')); + expect(await this.multicallToken.balanceOf(bob)).to.be.bignumber.equal(new BN('0')); + + await this.multicallToken.multicall([ + this.multicallToken.contract.methods.transfer(alice, amount / 2).encodeABI(), + this.multicallToken.contract.methods.transfer(bob, amount / 3).encodeABI(), + ], { from: deployer }); + + expect(await this.multicallToken.balanceOf(alice)).to.be.bignumber.equal(new BN(amount / 2)); + expect(await this.multicallToken.balanceOf(bob)).to.be.bignumber.equal(new BN(amount / 3)); + }); + + it('returns an array with the result of each call', async function () { + const MulticallTest = artifacts.require('MulticallTest'); + const multicallTest = await MulticallTest.new({ from: deployer }); + await this.multicallToken.transfer(multicallTest.address, amount, { from: deployer }); + expect(await this.multicallToken.balanceOf(multicallTest.address)).to.be.bignumber.equal(new BN(amount)); + + const recipients = [alice, bob]; + const amounts = [amount / 2, amount / 3].map(n => new BN(n)); + + await multicallTest.testReturnValues(this.multicallToken.address, recipients, amounts); + }); + + it('reverts previous calls', async function () { + expect(await this.multicallToken.balanceOf(alice)).to.be.bignumber.equal(new BN('0')); + + const call = this.multicallToken.multicall([ + this.multicallToken.contract.methods.transfer(alice, amount).encodeABI(), + this.multicallToken.contract.methods.transfer(bob, amount).encodeABI(), + ], { from: deployer }); + + await expectRevert(call, 'ERC20: transfer amount exceeds balance'); + expect(await this.multicallToken.balanceOf(alice)).to.be.bignumber.equal(new BN('0')); + }); + + it('bubbles up revert reasons', async function () { + const call = this.multicallToken.multicall([ + this.multicallToken.contract.methods.transfer(alice, amount).encodeABI(), + this.multicallToken.contract.methods.transfer(bob, amount).encodeABI(), + ], { from: deployer }); + + await expectRevert(call, 'ERC20: transfer amount exceeds balance'); + }); +});