diff --git a/.gas-snapshot b/.gas-snapshot index fbb08090..163f79c8 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -204,6 +204,15 @@ MultiRolesAuthorityTest:testSetPublicCapabilities() (gas: 27762) MultiRolesAuthorityTest:testSetRoleCapabilities() (gas: 28968) MultiRolesAuthorityTest:testSetRoles() (gas: 28971) MultiRolesAuthorityTest:testSetTargetCustomAuthority() (gas: 27976) +MulticallableTest:testMulticallableBenchmark() (gas: 34360) +MulticallableTest:testMulticallableOriginalBenchmark() (gas: 39134) +MulticallableTest:testMulticallablePreservesMsgSender() (gas: 11665) +MulticallableTest:testMulticallablePreservesMsgValue() (gas: 38091) +MulticallableTest:testMulticallablePreservesMsgValueUsedTwice() (gas: 40277) +MulticallableTest:testMulticallableReturnDataIsProperlyEncoded() (gas: 12803) +MulticallableTest:testMulticallableRevertWithCustomError() (gas: 10126) +MulticallableTest:testMulticallableRevertWithMessage() (gas: 11948) +MulticallableTest:testMulticallableWithNoData() (gas: 6496) OwnedTest:testCallFunctionAsNonOwner() (gas: 11311) OwnedTest:testCallFunctionAsOwner() (gas: 10479) OwnedTest:testSetOwner() (gas: 13035) diff --git a/lib/ds-test b/lib/ds-test index c7a36fb2..2c7dbcc8 160000 --- a/lib/ds-test +++ b/lib/ds-test @@ -1 +1 @@ -Subproject commit c7a36fb236f298e04edf28e2fee385b80f53945f +Subproject commit 2c7dbcc8586b33f358e3307a443e524490c17666 diff --git a/src/test/Multicallable.t.sol b/src/test/Multicallable.t.sol new file mode 100644 index 00000000..5738042b --- /dev/null +++ b/src/test/Multicallable.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import {DSTestPlus} from "./utils/DSTestPlus.sol"; + +import {MockMulticallable} from "./utils/mocks/MockMulticallable.sol"; + +contract MulticallableTest is DSTestPlus { + MockMulticallable multicallable; + + function setUp() public { + multicallable = new MockMulticallable(); + } + + function testMulticallableRevertWithMessage(string memory revertMessage) public { + bytes[] memory data = new bytes[](1); + data[0] = abi.encodeWithSelector(MockMulticallable.revertsWithString.selector, revertMessage); + hevm.expectRevert(bytes(revertMessage)); + multicallable.multicall(data); + } + + function testMulticallableRevertWithMessage() public { + testMulticallableRevertWithMessage("Milady"); + } + + function testMulticallableRevertWithCustomError() public { + bytes[] memory data = new bytes[](1); + data[0] = abi.encodeWithSelector(MockMulticallable.revertsWithCustomError.selector); + hevm.expectRevert(MockMulticallable.CustomError.selector); + multicallable.multicall(data); + } + + function testMulticallableReturnDataIsProperlyEncoded( + uint256 a0, + uint256 b0, + uint256 a1, + uint256 b1 + ) public { + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(MockMulticallable.returnsTuple.selector, a0, b0); + data[1] = abi.encodeWithSelector(MockMulticallable.returnsTuple.selector, a1, b1); + bytes[] memory returnedData = multicallable.multicall(data); + MockMulticallable.Tuple memory t0 = abi.decode(returnedData[0], (MockMulticallable.Tuple)); + MockMulticallable.Tuple memory t1 = abi.decode(returnedData[1], (MockMulticallable.Tuple)); + assertEq(t0.a, a0); + assertEq(t0.b, b0); + assertEq(t1.a, a1); + assertEq(t1.b, b1); + } + + function testMulticallableReturnDataIsProperlyEncoded(string memory sIn) public { + bytes[] memory data = new bytes[](1); + data[0] = abi.encodeWithSelector(MockMulticallable.returnsString.selector, sIn); + string memory sOut = abi.decode(multicallable.multicall(data)[0], (string)); + assertEq(sIn, sOut); + } + + function testMulticallableReturnDataIsProperlyEncoded() public { + testMulticallableReturnDataIsProperlyEncoded(0, 1, 2, 3); + } + + function testMulticallableBenchmark() public { + unchecked { + bytes[] memory data = new bytes[](10); + for (uint256 i; i != data.length; ++i) { + data[i] = abi.encodeWithSelector(MockMulticallable.returnsTuple.selector, i, i + 1); + } + bytes[] memory returnedData = multicallable.multicall(data); + assertEq(returnedData.length, data.length); + } + } + + function testMulticallableOriginalBenchmark() public { + unchecked { + bytes[] memory data = new bytes[](10); + for (uint256 i; i != data.length; ++i) { + data[i] = abi.encodeWithSelector(MockMulticallable.returnsTuple.selector, i, i + 1); + } + bytes[] memory returnedData = multicallable.multicallOriginal(data); + assertEq(returnedData.length, data.length); + } + } + + function testMulticallableWithNoData() public { + bytes[] memory data = new bytes[](0); + assertEq(multicallable.multicall(data).length, 0); + } + + function testMulticallablePreservesMsgValue() public { + bytes[] memory data = new bytes[](1); + data[0] = abi.encodeWithSelector(MockMulticallable.pay.selector); + multicallable.multicall{value: 3}(data); + assertEq(multicallable.paid(), 3); + } + + function testMulticallablePreservesMsgValueUsedTwice() public { + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(MockMulticallable.pay.selector); + data[1] = abi.encodeWithSelector(MockMulticallable.pay.selector); + multicallable.multicall{value: 3}(data); + assertEq(multicallable.paid(), 6); + } + + function testMulticallablePreservesMsgSender() public { + address caller = address(uint160(0xbeef)); + bytes[] memory data = new bytes[](1); + data[0] = abi.encodeWithSelector(MockMulticallable.returnsSender.selector); + hevm.prank(caller); + address returnedAddress = abi.decode(multicallable.multicall(data)[0], (address)); + assertEq(caller, returnedAddress); + } +} diff --git a/src/test/utils/mocks/MockMulticallable.sol b/src/test/utils/mocks/MockMulticallable.sol new file mode 100644 index 00000000..8b8a8eaa --- /dev/null +++ b/src/test/utils/mocks/MockMulticallable.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import "../../../utils/Multicallable.sol"; + +contract MockMulticallable is Multicallable { + error CustomError(); + + struct Tuple { + uint256 a; + uint256 b; + } + + function revertsWithString(string memory e) external pure { + revert(e); + } + + function revertsWithCustomError() external pure { + revert CustomError(); + } + + function revertsWithNothing() external pure { + revert(); + } + + function returnsTuple(uint256 a, uint256 b) external pure returns (Tuple memory tuple) { + tuple = Tuple({a: a, b: b}); + } + + function returnsString(string calldata s) external pure returns (string memory) { + return s; + } + + uint256 public paid; + + function pay() external payable { + paid += msg.value; + } + + function returnsSender() external view returns (address) { + return msg.sender; + } + + function multicallOriginal(bytes[] calldata data) public payable returns (bytes[] memory results) { + unchecked { + 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) { + // Next 5 lines from https://ethereum.stackexchange.com/a/83577 + if (result.length < 68) revert(); + assembly { + result := add(result, 0x04) + } + revert(abi.decode(result, (string))); + } + results[i] = result; + } + } + } +} diff --git a/src/utils/Multicallable.sol b/src/utils/Multicallable.sol new file mode 100644 index 00000000..ed14d851 --- /dev/null +++ b/src/utils/Multicallable.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// @notice Contract that enables a single call to call multiple methods on itself. +/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/Multicallable.sol) +/// @author Modified from Solady (https://github.com/vectorized/solady/blob/main/src/utils/Multicallable.sol) +/// @dev WARNING! +/// Multicallable is NOT SAFE for use in contracts with checks / requires on `msg.value` +/// (e.g. in NFT minting / auction contracts) without a suitable nonce mechanism. +/// It WILL open up your contract to double-spend vulnerabilities / exploits. +/// See: (https://www.paradigm.xyz/2021/08/two-rights-might-make-a-wrong/) +abstract contract Multicallable { + function multicall(bytes[] calldata data) public payable returns (bytes[] memory results) { + assembly { + if data.length { + results := mload(0x40) // Point `results` to start of free memory. + mstore(results, data.length) // Store `data.length` into `results`. + results := add(results, 0x20) + + // `shl` 5 is equivalent to multiplying by 0x20. + let end := shl(5, data.length) + // Copy the offsets from calldata into memory. + calldatacopy(results, data.offset, end) + // Pointer to the top of the memory (i.e. start of the free memory). + let memPtr := add(results, end) + end := add(results, end) + + // prettier-ignore + for {} 1 {} { + // The offset of the current bytes in the calldata. + let o := add(data.offset, mload(results)) + // Copy the current bytes from calldata to the memory. + calldatacopy( + memPtr, + add(o, 0x20), // The offset of the current bytes' bytes. + calldataload(o) // The length of the current bytes. + ) + if iszero(delegatecall(gas(), address(), memPtr, calldataload(o), 0x00, 0x00)) { + // Bubble up the revert if the delegatecall reverts. + returndatacopy(0x00, 0x00, returndatasize()) + revert(0x00, returndatasize()) + } + // Append the current `memPtr` into `results`. + mstore(results, memPtr) + results := add(results, 0x20) + // Append the `returndatasize()`, and the return data. + mstore(memPtr, returndatasize()) + returndatacopy(add(memPtr, 0x20), 0x00, returndatasize()) + // Advance the `memPtr` by `returndatasize() + 0x20`, + // rounded up to the next multiple of 32. + memPtr := and(add(add(memPtr, returndatasize()), 0x3f), 0xffffffffffffffe0) + // prettier-ignore + if iszero(lt(results, end)) { break } + } + // Restore `results` and allocate memory for it. + results := mload(0x40) + mstore(0x40, memPtr) + } + } + } +}