From 4b194cc87e47c5f38e1fb1a06ad2d21e5d876328 Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Sat, 21 Feb 2026 13:47:53 +0400 Subject: [PATCH 1/9] feat: introduce lightweighted multicall for the same target --- contracts/MultiCall.sol | 84 ++++++++++++ contracts/mocks/MultiCallTestTarget.sol | 22 +++ test/EvmHelpers.js | 3 - test/multicall/MultiCall.js | 172 ++++++++++++++++++++++++ test/multicall/utils.js | 85 ++++++++++++ 5 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 contracts/mocks/MultiCallTestTarget.sol delete mode 100644 test/EvmHelpers.js create mode 100644 test/multicall/MultiCall.js create mode 100644 test/multicall/utils.js diff --git a/contracts/MultiCall.sol b/contracts/MultiCall.sol index 7b8ceac..970857e 100644 --- a/contracts/MultiCall.sol +++ b/contracts/MultiCall.sol @@ -62,6 +62,90 @@ contract MultiCall { } } + /** + * @notice Executes multiple calls in a single transaction (Yul implementation); reads payload from calldata. + * @dev All calls are made to the same target. returnWordIndex per call selects which 32-byte word of returndata to use (0 = first word). + * + * Calldata layout: + * 4 bytes - selector (multicallOneTargetPacked()) + * 2 bytes - numCalls + * 20 bytes - target address + * For each call: + * 32 bytes - header (1 byte returnWordIndex | 31 bytes dataLength) + * N bytes - call data (length = dataLength) + * + * @return result ABI-encoded bytes as above. + * + * 32 bytes - payload length + * For each call (32 bytes per packed word): + * 1 bit - success (0 or 1) + * 28 bits - gasUsed + * 227 bits - selected return word (value) + */ + function multicallOneTargetPacked() external returns (bytes memory result) { + assembly { + if lt(calldatasize(), 26) { + revert(0, 0) + } + + let numCalls := shr(240, calldataload(4)) + let target := shr(96, calldataload(6)) + + let ptr := mload(0x40) + if iszero(numCalls) { + mstore(ptr, 0x20) + mstore(add(ptr, 0x20), 0) + return(ptr, 0x40) + } + + let calldataPtr := 26 + + let resultsPtr := add(ptr, 0x20) + let totalSize := mul(32, numCalls) + mstore(ptr, totalSize) + mstore(0x40, add(resultsPtr, totalSize)) + + let endPtr := add(resultsPtr, totalSize) + + for { let i := resultsPtr } lt(i, endPtr) { i := add(i, 32) } { + let header := calldataload(calldataPtr) + let returnWordIndex := shr(248, header) + let dataLength := and(header, 0x00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + calldataPtr := add(calldataPtr, 32) + + if gt(add(calldataPtr, dataLength), calldatasize()) { + revert(0, 0) + } + + calldatacopy(endPtr, calldataPtr, dataLength) + let g := gas() + let success := call(g, target, 0, endPtr, dataLength, 0, 0) + let gasUsedVal := sub(g, gas()) + + let offset := mul(returnWordIndex, 32) + + let returnWord := 0 + if and(success, iszero(lt(returndatasize(), add(offset, 32)))) { + returndatacopy(0, offset, 32) + returnWord := mload(0) + } + + let packed := or( + or( + shl(255, success), + shl(227, and(gasUsedVal, 0x0fffffff)) + ), + and(returnWord, 0x0000000000000007ffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + ) + mstore(i, packed) + + calldataPtr := add(calldataPtr, dataLength) + } + + result := ptr + } + } + /// @notice Fetches the block gas limit. /// @return result The block gas limit. function gaslimit() external view returns (uint256) { diff --git a/contracts/mocks/MultiCallTestTarget.sol b/contracts/mocks/MultiCallTestTarget.sol new file mode 100644 index 0000000..1873af7 --- /dev/null +++ b/contracts/mocks/MultiCallTestTarget.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +contract MultiCallTestTarget { + error TestRevert(); + + function getUint() external pure returns (uint256) { + return 42; + } + + function getSeveralWords(uint256 x, uint256 y, uint256 z, uint256 w, uint256 v) external view returns (uint256 a, uint256 b, uint256 c, uint256 d, uint256 e) { + a = x; + b = y; + c = z; + d = w; + e = v; + } + + function doRevert() external view { + revert TestRevert(); + } +} diff --git a/test/EvmHelpers.js b/test/EvmHelpers.js deleted file mode 100644 index a8b0056..0000000 --- a/test/EvmHelpers.js +++ /dev/null @@ -1,3 +0,0 @@ -describe('EvmHelpers', async function () { - // todo -}); diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js new file mode 100644 index 0000000..54c1df2 --- /dev/null +++ b/test/multicall/MultiCall.js @@ -0,0 +1,172 @@ +const { ethers } = require('hardhat'); +const { expect } = require('@1inch/solidity-utils'); +const { + unpackResult, + callMulticallOneTargetPackedAndMeasureGas, + callMulticallWithGasAndMeasureGas, +} = require('./utils'); + +describe('MultiCall', function () { + let multiCall; + let target; + let targetAddress; + + before(async function () { + multiCall = await (await ethers.getContractFactory('MultiCall')).deploy(); + await multiCall.waitForDeployment(); + target = await (await ethers.getContractFactory('MultiCallTestTarget')).deploy(); + await target.waitForDeployment(); + targetAddress = await target.getAddress() + }); + + describe('multicallOneTargetPacked', function () { + + it('returns empty array when numCalls is 0', async function () { + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), []); + expect(decodedArray).to.have.lengthOf(0); + expect(estimatedGas).to.be.lte(21656); + expect(estimatedGas - totalPerCallGas).to.be.lte(21656); + }); + + it('single successful call: returnWordIndex 0, parses first 32 bytes', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas( + multiCall, + await target.getAddress(), + [{ data: getUintCalldata, returnWordIndex: 0 }] + ); + expect(decodedArray).to.have.lengthOf(1); + const { success, gasUsed, value } = unpackResult(decodedArray[0]); + expect(success).to.equal(true); + expect(gasUsed).to.be.gt(0); + expect(value).to.equal(42n); + expect(estimatedGas).to.be.lte(25264); + expect(estimatedGas - totalPerCallGas).to.be.lte(22499); + }); + + it('single successful call: returnWordIndex 1, parses second 32 bytes', async function () { + const getSeveralWordsCalldata = target.interface.encodeFunctionData('getSeveralWords', [1, 2, 3, 4, 5]); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), [{ data: getSeveralWordsCalldata, returnWordIndex: 1 }]); + expect(decodedArray).to.have.lengthOf(1); + const { success, gasUsed, value } = unpackResult(decodedArray[0]); + expect(success).to.equal(true); + expect(gasUsed).to.be.gt(0); + expect(value).to.equal(2n); + expect(estimatedGas).to.be.lte(26124); + expect(estimatedGas - totalPerCallGas).to.be.lte(23244); + }); + + it('failed call: success bit 0, gasUsed set, value 0', async function () { + const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), [{ data: doRevertCalldata, returnWordIndex: 0 }]); + expect(decodedArray).to.have.lengthOf(1); + const { success, gasUsed, value } = unpackResult(decodedArray[0]); + expect(success).to.equal(false); + expect(gasUsed).to.be.gt(0); + expect(value).to.equal(0n); + expect(estimatedGas).to.be.lte(25283); + expect(estimatedGas - totalPerCallGas).to.be.lte(22472); + }); + + it('multiple calls: mix success and failure', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), [ + { data: getUintCalldata, returnWordIndex: 0 }, + { data: doRevertCalldata, returnWordIndex: 0 }, + { data: getUintCalldata, returnWordIndex: 0 }, + ]); + expect(decodedArray).to.have.lengthOf(3); + const r0 = unpackResult(decodedArray[0]); + const r1 = unpackResult(decodedArray[1]); + const r2 = unpackResult(decodedArray[2]); + expect(r0.success).to.equal(true); + expect(r0.value).to.equal(42n); + expect(r1.success).to.equal(false); + expect(r1.value).to.equal(0n); + expect(r2.success).to.equal(true); + expect(r2.value).to.equal(42n); + expect(estimatedGas).to.be.lte(26935); + expect(estimatedGas - totalPerCallGas).to.be.lte(23594); + }); + + it('5 calls: all successful', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + const calls = Array(5).fill({ data: getUintCalldata, returnWordIndex: 0 }); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), calls); + expect(decodedArray).to.have.lengthOf(5); + for (let i = 0; i < 5; i++) { + const { success, value } = unpackResult(decodedArray[i]); + expect(success).to.equal(true); + expect(value).to.equal(42n); + } + expect(estimatedGas).to.be.lte(28568); + expect(estimatedGas - totalPerCallGas).to.be.lte(24743); + }); + }); + + describe('multicallWithGas', function () { + function toCall(data) { + return { to: targetAddress, data }; + } + + it('returns empty array when numCalls is 0', async function () { + const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, []); + expect(results).to.have.lengthOf(0); + expect(gasUsed).to.have.lengthOf(0); + expect(estimatedGas).to.be.lte(22908); + expect(estimatedGas - totalPerCallGas).to.be.lte(22908); + }); + + it('single successful call', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + + const calls = [toCall(getUintCalldata)]; + const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + expect(results).to.have.lengthOf(1); + expect(BigInt(results[0])).to.equal(42n); + expect(gasUsed[0]).to.be.gt(0); + expect(estimatedGas).to.be.lte(28509); + expect(estimatedGas - totalPerCallGas).to.be.lte(25205); + }); + + it('failed call', async function () { + const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); + const calls = [toCall(doRevertCalldata)]; + const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + expect(results).to.have.lengthOf(1); + expect(gasUsed[0]).to.be.gt(0); + expect(estimatedGas).to.be.lte(28567); + expect(estimatedGas - totalPerCallGas).to.be.lte(25217); + }); + + it('multiple calls: mix success and failure', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); + const calls = [ + toCall(getUintCalldata), + toCall(doRevertCalldata), + toCall(getUintCalldata), + ]; + const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + expect(results).to.have.lengthOf(3); + expect(BigInt(results[0])).to.equal(42n); + expect(gasUsed[1]).to.be.gt(0); + expect(BigInt(results[2])).to.equal(42n); + expect(estimatedGas).to.be.lte(34758); + expect(estimatedGas - totalPerCallGas).to.be.lte(29799); + }); + + it('5 calls: all successful', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + const calls = Array(5).fill(null).map(() => toCall(getUintCalldata)); + const { results, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + expect(results).to.have.lengthOf(5); + for (let i = 0; i < 5; i++) { + expect(BigInt(results[i])).to.equal(42n); + } + expect(estimatedGas).to.be.lte(40919); + expect(estimatedGas - totalPerCallGas).to.be.lte(34397); + }); + }); +}); diff --git a/test/multicall/utils.js b/test/multicall/utils.js new file mode 100644 index 0000000..ad5248e --- /dev/null +++ b/test/multicall/utils.js @@ -0,0 +1,85 @@ +const { ethers } = require('hardhat'); + +// Packed result: bit 255 = success (1 bit), bits 254-227 = gasUsed (28 bits), bits 226-0 = value (227 bits) +const GAS_USED_MASK = (1n << 255n) - (1n << 227n); // bits 254-227 (28 bits) +const VALUE_MASK = (1n << 227n) - 1n; // bits 226-0 + +function unpackResult(r) { + const success = ((r >> 255n) & 1n) !== 0n; + const gasUsed = Number((r & GAS_USED_MASK) >> 227n); + const value = r & VALUE_MASK; + return { success, gasUsed, value }; +} + +function decodeBytesToPackedUint256Array(callResultHex) { + if (!callResultHex || callResultHex === '0x') return []; + const bytesHex = ethers.AbiCoder.defaultAbiCoder().decode(['bytes'], callResultHex)[0]; + if (!bytesHex || bytesHex === '0x') return []; + const data = ethers.getBytes(bytesHex); + const count = Math.floor(data.length / 32); + const arr = []; + for (let i = 0; i < count; i++) { + const chunk = data.slice(i * 32, (i + 1) * 32); + arr.push(chunk.length === 0 ? 0n : ethers.toBigInt(ethers.hexlify(chunk))); + } + return arr; +} + +// Build raw calldata for multicallOneTargetPacked: selector + numCalls(2) + target(20) + [header(32) + data]* +// Header = 32-byte word: highest byte = returnWordIndex, lower 31 bytes = dataLength. Each call is { data: hexString, returnWordIndex: number }. +function buildMulticallOneTargetPackedCalldata(targetAddress, calls) { + const selector = ethers.id('multicallOneTargetPacked()').slice(0, 10); + const numCallsBytes = '0x' + calls.length.toString(16).padStart(4, '0'); + const target20 = ethers.zeroPadValue(ethers.getAddress(targetAddress), 20); + + const parts = [ + selector, + numCallsBytes, + target20, + ]; + + for (const { data: callData, returnWordIndex } of calls) { + const lenBytes = ethers.getBytes(callData).length; + const header = (BigInt(returnWordIndex) << 248n) | BigInt(lenBytes); + parts.push(ethers.toBeHex(header, 32)); + parts.push(callData); + } + + return ethers.concat(parts); +} + +// Call multicallOneTargetPacked and return decoded results plus gas metrics (estimated gas, per-call gas from packed results). +// calls: array of { data: hexString, returnWordIndex: number }. +async function callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls) { + const data = buildMulticallOneTargetPackedCalldata(targetAddress, calls); + const [result, estimatedGas] = await Promise.all([ + multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }), + multiCall.runner.provider.estimateGas({ to: await multiCall.getAddress(), data }), + ]); + const decodedArray = decodeBytesToPackedUint256Array(result); + const perCallGas = decodedArray.map((r) => unpackResult(r).gasUsed); + const totalPerCallGas = perCallGas.reduce((s, g) => s + g, 0); + return { decodedArray, estimatedGas: Number(estimatedGas), perCallGas, totalPerCallGas }; +} + +// Call multicallWithGas and return results, gasUsed, estimatedGas, and sum(gasUsed). calls: array of { to: address, data: hexString }. +async function callMulticallWithGasAndMeasureGas(multiCall, calls) { + const calldata = multiCall.interface.encodeFunctionData('multicallWithGas', [calls]); + const [result, estimatedGas] = await Promise.all([ + multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: calldata }), + multiCall.runner.provider.estimateGas({ to: await multiCall.getAddress(), data: calldata }), + ]); + const [results, gasUsed] = multiCall.interface.decodeFunctionResult('multicallWithGas', result); + const totalPerCallGas = gasUsed.reduce((s, g) => s + Number(g), 0); + return { results, gasUsed: gasUsed.map(Number), estimatedGas: Number(estimatedGas), totalPerCallGas }; +} + +module.exports = { + GAS_USED_MASK, + VALUE_MASK, + unpackResult, + decodeBytesToPackedUint256Array, + buildMulticallOneTargetPackedCalldata, + callMulticallOneTargetPackedAndMeasureGas, + callMulticallWithGasAndMeasureGas, +}; From bdbac1532bcb3779d41af49b6d1f7d5dbb54c66a Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Sat, 21 Feb 2026 13:51:13 +0400 Subject: [PATCH 2/9] chore: add tests for 100 calls --- test/multicall/MultiCall.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index 54c1df2..48ecb94 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -103,6 +103,20 @@ describe('MultiCall', function () { expect(estimatedGas).to.be.lte(28568); expect(estimatedGas - totalPerCallGas).to.be.lte(24743); }); + + it('100 calls: all successful', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + const calls = Array(100).fill({ data: getUintCalldata, returnWordIndex: 0 }); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), calls); + expect(decodedArray).to.have.lengthOf(100); + for (let i = 0; i < 100; i++) { + const { success, value } = unpackResult(decodedArray[i]); + expect(success).to.equal(true); + expect(value).to.equal(42n); + } + expect(estimatedGas).to.be.lte(107122); + expect(estimatedGas - totalPerCallGas).to.be.lte(78122); + }); }); describe('multicallWithGas', function () { @@ -168,5 +182,17 @@ describe('MultiCall', function () { expect(estimatedGas).to.be.lte(40919); expect(estimatedGas - totalPerCallGas).to.be.lte(34397); }); + + it('100 calls: all successful', async function () { + const getUintCalldata = target.interface.encodeFunctionData('getUint'); + const calls = Array(100).fill(null).map(() => toCall(getUintCalldata)); + const { results, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + expect(results).to.have.lengthOf(100); + for (let i = 0; i < 100; i++) { + expect(BigInt(results[i])).to.equal(42n); + } + expect(estimatedGas).to.be.lte(338176); + expect(estimatedGas - totalPerCallGas).to.be.lte(254646); + }); }); }); From f27a86ebb517a85ceaa1167867084f1920b03331 Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Mon, 23 Feb 2026 19:13:22 +0400 Subject: [PATCH 3/9] feat: introduce multicallOneTargetPackedPatchable --- contracts/MultiCall.sol | 134 ++++++++++++++++++++++++++++++++---- test/multicall/MultiCall.js | 114 +++++++++++++++++++++++++++--- test/multicall/utils.js | 51 ++++++++++++-- 3 files changed, 268 insertions(+), 31 deletions(-) diff --git a/contracts/MultiCall.sol b/contracts/MultiCall.sol index 970857e..719d907 100644 --- a/contracts/MultiCall.sol +++ b/contracts/MultiCall.sol @@ -74,15 +74,13 @@ contract MultiCall { * 32 bytes - header (1 byte returnWordIndex | 31 bytes dataLength) * N bytes - call data (length = dataLength) * - * @return result ABI-encoded bytes as above. - * - * 32 bytes - payload length + * @return result ABI-encoded bytes: * For each call (32 bytes per packed word): * 1 bit - success (0 or 1) * 28 bits - gasUsed * 227 bits - selected return word (value) */ - function multicallOneTargetPacked() external returns (bytes memory result) { + function multicallOneTargetPacked() external returns (bytes memory) { assembly { if lt(calldatasize(), 26) { revert(0, 0) @@ -91,21 +89,21 @@ contract MultiCall { let numCalls := shr(240, calldataload(4)) let target := shr(96, calldataload(6)) - let ptr := mload(0x40) if iszero(numCalls) { - mstore(ptr, 0x20) - mstore(add(ptr, 0x20), 0) - return(ptr, 0x40) + mstore(0x00, 0x20) + mstore(0x20, 0) + return(0x00, 0x40) } - let calldataPtr := 26 - - let resultsPtr := add(ptr, 0x20) + let ptr := mload(0x40) + mstore(ptr, 0x20) let totalSize := mul(32, numCalls) - mstore(ptr, totalSize) - mstore(0x40, add(resultsPtr, totalSize)) - + mstore(add(ptr, 0x20), totalSize) + let resultsPtr := add(ptr, 0x40) let endPtr := add(resultsPtr, totalSize) + mstore(0x40, endPtr) + + let calldataPtr := 26 for { let i := resultsPtr } lt(i, endPtr) { i := add(i, 32) } { let header := calldataload(calldataPtr) @@ -142,7 +140,113 @@ contract MultiCall { calldataPtr := add(calldataPtr, dataLength) } - result := ptr + return(ptr, add(totalSize, 0x40)) + } + } + + /** + * @notice Executes multiple calls in a single transaction with patchable calldata; reads payload from calldata. + * @dev All calls are made to the same target. Each entry has one base calldata and multiple patch values; for each patch value + * the base calldata is copied, the value is written at patchOffset, then the call is made. returnWordIndex selects which + * 32-byte word of returndata to use (0 = first word). numCalls must equal the total number of patch values across all entries. + * + * Calldata layout: + * 4 bytes - selector (multicallOneTargetPackedPatchable()) + * 2 bytes - numCalls (total number of calls) + * 2 bytes - numCalldatas (number of base calldata entries) + * 20 bytes - target address + * For each calldata entry: + * 32 bytes - header (1 byte returnWordIndex | 2 bytes numPatches | 2 bytes patchOffset | dataLength in low bits) + * N bytes - base call data (length = dataLength) + * numPatches * 32 bytes - patch values (each written at patchOffset in a copy of base data before the call) + * + * @return result ABI-encoded bytes: + * For each call (32 bytes per packed word): + * 1 bit - success (0 or 1) + * 28 bits - gasUsed + * 227 bits - selected return word (value) + */ + function multicallOneTargetPackedPatchable() external returns (bytes memory) { + assembly { + if lt(calldatasize(), 28) { + revert(0, 0) + } + + let numCalls := shr(240, calldataload(4)) + let numCalldatas := shr(240, calldataload(6)) + let target := shr(96, calldataload(8)) + + if gt(numCalldatas, numCalls) { + revert(0, 0) + } + + if iszero(numCalls) { + mstore(0x00, 0x20) + mstore(0x20, 0) + return(0x00, 0x40) + } + + let ptr := mload(0x40) + mstore(ptr, 0x20) + let totalSize := mul(32, numCalls) + mstore(add(ptr, 0x20), totalSize) + let resultsPtr := add(ptr, 0x40) + let endPtr := add(resultsPtr, totalSize) + mstore(0x40, endPtr) + + let resultIdx := resultsPtr + + let calldataPtr := 28 + + for { let cdIdx := numCalldatas } cdIdx { cdIdx := sub(cdIdx, 1) } { + let header := calldataload(calldataPtr) + let returnWordIndex := shr(248, header) + let numPatches := and(shr(232, header), 0xffff) + let patchOffset := and(shr(216, header), 0xffff) + let dataLength := and(header, 0x00000000000000ffffffffffffffffffffffffffffffffffffffffffffffffff) + calldataPtr := add(calldataPtr, 32) + + let patchesSize := mul(numPatches, 32) + let calldataEnd := add(calldataPtr, dataLength) + let patchesEnd := add(calldataEnd, patchesSize) + if gt(patchesEnd, calldatasize()) { + revert(0, 0) + } + + calldatacopy(endPtr, calldataPtr, dataLength) + + let offset := mul(returnWordIndex, 32) + + let patchOffsetPtr := add(endPtr, patchOffset) + + for { let j := calldataEnd } lt(j, patchesEnd) { j := add(j, 0x20) } { + mstore(patchOffsetPtr, calldataload(j)) + + let g := gas() + let success := call(g, target, 0, endPtr, dataLength, 0, 0) + let gasUsedVal := sub(g, gas()) + + let returnWord := 0 + if and(success, iszero(lt(returndatasize(), add(offset, 32)))) { + returndatacopy(0, offset, 32) + returnWord := mload(0) + } + + let packed := or( + or( + shl(255, success), + shl(227, and(gasUsedVal, 0x0fffffff)) + ), + and(returnWord, 0x0000000000000007ffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + ) + mstore(resultIdx, packed) + resultIdx := add(resultIdx, 32) + } + + calldataPtr := patchesEnd + } + + return(ptr, add(totalSize, 0x40)) } } diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index 48ecb94..0049b2d 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -2,7 +2,9 @@ const { ethers } = require('hardhat'); const { expect } = require('@1inch/solidity-utils'); const { unpackResult, + buildMulticallOneTargetPackedPatchableCalldata, callMulticallOneTargetPackedAndMeasureGas, + callMulticallOneTargetPackedPatchableAndMeasureGas, callMulticallWithGasAndMeasureGas, } = require('./utils'); @@ -16,13 +18,12 @@ describe('MultiCall', function () { await multiCall.waitForDeployment(); target = await (await ethers.getContractFactory('MultiCallTestTarget')).deploy(); await target.waitForDeployment(); - targetAddress = await target.getAddress() + targetAddress = await target.getAddress(); }); describe('multicallOneTargetPacked', function () { - it('returns empty array when numCalls is 0', async function () { - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), []); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, []); expect(decodedArray).to.have.lengthOf(0); expect(estimatedGas).to.be.lte(21656); expect(estimatedGas - totalPerCallGas).to.be.lte(21656); @@ -32,8 +33,8 @@ describe('MultiCall', function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas( multiCall, - await target.getAddress(), - [{ data: getUintCalldata, returnWordIndex: 0 }] + targetAddress, + [{ data: getUintCalldata, returnWordIndex: 0 }], ); expect(decodedArray).to.have.lengthOf(1); const { success, gasUsed, value } = unpackResult(decodedArray[0]); @@ -46,7 +47,11 @@ describe('MultiCall', function () { it('single successful call: returnWordIndex 1, parses second 32 bytes', async function () { const getSeveralWordsCalldata = target.interface.encodeFunctionData('getSeveralWords', [1, 2, 3, 4, 5]); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), [{ data: getSeveralWordsCalldata, returnWordIndex: 1 }]); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas( + multiCall, + targetAddress, + [{ data: getSeveralWordsCalldata, returnWordIndex: 1 }], + ); expect(decodedArray).to.have.lengthOf(1); const { success, gasUsed, value } = unpackResult(decodedArray[0]); expect(success).to.equal(true); @@ -58,7 +63,11 @@ describe('MultiCall', function () { it('failed call: success bit 0, gasUsed set, value 0', async function () { const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), [{ data: doRevertCalldata, returnWordIndex: 0 }]); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas( + multiCall, + targetAddress, + [{ data: doRevertCalldata, returnWordIndex: 0 }], + ); expect(decodedArray).to.have.lengthOf(1); const { success, gasUsed, value } = unpackResult(decodedArray[0]); expect(success).to.equal(false); @@ -71,7 +80,7 @@ describe('MultiCall', function () { it('multiple calls: mix success and failure', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), [ + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, [ { data: getUintCalldata, returnWordIndex: 0 }, { data: doRevertCalldata, returnWordIndex: 0 }, { data: getUintCalldata, returnWordIndex: 0 }, @@ -93,7 +102,7 @@ describe('MultiCall', function () { it('5 calls: all successful', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const calls = Array(5).fill({ data: getUintCalldata, returnWordIndex: 0 }); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), calls); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls); expect(decodedArray).to.have.lengthOf(5); for (let i = 0; i < 5; i++) { const { success, value } = unpackResult(decodedArray[i]); @@ -107,7 +116,7 @@ describe('MultiCall', function () { it('100 calls: all successful', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const calls = Array(100).fill({ data: getUintCalldata, returnWordIndex: 0 }); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, await target.getAddress(), calls); + const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls); expect(decodedArray).to.have.lengthOf(100); for (let i = 0; i < 100; i++) { const { success, value } = unpackResult(decodedArray[i]); @@ -119,8 +128,61 @@ describe('MultiCall', function () { }); }); + describe('multicallOneTargetPackedPatchable', function () { + it('one calldata × one patch value: one call', async function () { + const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n] }]; + const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); + expect(decodedArray).to.have.lengthOf(1); + const { success, value } = unpackResult(decodedArray[0]); + expect(success).to.equal(true); + expect(value).to.equal(1n); + }); + + it('one calldata × 5 patch values at same offset: 5 calls', async function () { + const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n, 2n, 3n, 4n, 5n] }]; + const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); + expect(decodedArray).to.have.lengthOf(5); + for (let i = 0; i < 5; i++) { + const { success, value } = unpackResult(decodedArray[i]); + expect(success).to.equal(true); + expect(value).to.equal(BigInt(i + 1)); + } + }); + + it('one calldata × 100 patch values: 100 calls', async function () { + const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const patchValues = Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n, 0n); + const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues }]; + const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); + expect(decodedArray).to.have.lengthOf(100); + for (let i = 0; i < 100; i++) { + const { success, value } = unpackResult(decodedArray[i]); + expect(success).to.equal(true); + expect(value).to.equal(BigInt(i + 1)); + } + }); + + it('two calldatas × two patch values each: 4 calls', async function () { + const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const calls = [ + { baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n, 2n] }, + { baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [3n, 4n] }, + ]; + const data = buildMulticallOneTargetPackedPatchableCalldata(targetAddress, calls); + expect(ethers.getBytes(data).length).to.equal(548); // 28 + 2*(32+164+64) + const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); + expect(decodedArray).to.have.lengthOf(4); + expect(unpackResult(decodedArray[0]).value).to.equal(1n); + expect(unpackResult(decodedArray[1]).value).to.equal(2n); + expect(unpackResult(decodedArray[2]).value).to.equal(3n); + expect(unpackResult(decodedArray[3]).value).to.equal(4n); + }); + }); + describe('multicallWithGas', function () { - function toCall(data) { + function toCall (data) { return { to: targetAddress, data }; } @@ -195,4 +257,34 @@ describe('MultiCall', function () { expect(estimatedGas - totalPerCallGas).to.be.lte(254646); }); }); + + describe('performance', function () { + it('getSeveralWords', async function () { + const calls = [{ + baseData: target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]), + returnWordIndex: 0, + patchOffset: 4, + patchValues: Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n), + }, + ]; + + const calls2 = Array.from({ length: 100 }, (_, i) => ({ + data: target.interface.encodeFunctionData('getSeveralWords', [BigInt(i) + 1n, 0, 0, 0, 0]), + returnWordIndex: 0, + })); + + const calls3 = Array.from({ length: 100 }, (_, i) => ({ + data: target.interface.encodeFunctionData('getSeveralWords', [BigInt(i) + 1n, 0, 0, 0, 0]), + to: targetAddress, + })); + + const multiCallOneTargetPackedPatchableResult = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); + const multiCallOneTargetPackedResult = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls2); + const multicallWithGasResult = await callMulticallWithGasAndMeasureGas(multiCall, calls3); + + expect(multicallWithGasResult.estimatedGas).to.be.eq(461_188); + expect(multiCallOneTargetPackedResult.estimatedGas).to.be.eq(178_302); + expect(multiCallOneTargetPackedPatchableResult.estimatedGas).to.be.eq(101_011); + }); + }); }); diff --git a/test/multicall/utils.js b/test/multicall/utils.js index ad5248e..5e1764d 100644 --- a/test/multicall/utils.js +++ b/test/multicall/utils.js @@ -4,14 +4,14 @@ const { ethers } = require('hardhat'); const GAS_USED_MASK = (1n << 255n) - (1n << 227n); // bits 254-227 (28 bits) const VALUE_MASK = (1n << 227n) - 1n; // bits 226-0 -function unpackResult(r) { +function unpackResult (r) { const success = ((r >> 255n) & 1n) !== 0n; const gasUsed = Number((r & GAS_USED_MASK) >> 227n); const value = r & VALUE_MASK; return { success, gasUsed, value }; } -function decodeBytesToPackedUint256Array(callResultHex) { +function decodeBytesToPackedUint256Array (callResultHex) { if (!callResultHex || callResultHex === '0x') return []; const bytesHex = ethers.AbiCoder.defaultAbiCoder().decode(['bytes'], callResultHex)[0]; if (!bytesHex || bytesHex === '0x') return []; @@ -27,7 +27,7 @@ function decodeBytesToPackedUint256Array(callResultHex) { // Build raw calldata for multicallOneTargetPacked: selector + numCalls(2) + target(20) + [header(32) + data]* // Header = 32-byte word: highest byte = returnWordIndex, lower 31 bytes = dataLength. Each call is { data: hexString, returnWordIndex: number }. -function buildMulticallOneTargetPackedCalldata(targetAddress, calls) { +function buildMulticallOneTargetPackedCalldata (targetAddress, calls) { const selector = ethers.id('multicallOneTargetPacked()').slice(0, 10); const numCallsBytes = '0x' + calls.length.toString(16).padStart(4, '0'); const target20 = ethers.zeroPadValue(ethers.getAddress(targetAddress), 20); @@ -50,7 +50,7 @@ function buildMulticallOneTargetPackedCalldata(targetAddress, calls) { // Call multicallOneTargetPacked and return decoded results plus gas metrics (estimated gas, per-call gas from packed results). // calls: array of { data: hexString, returnWordIndex: number }. -async function callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls) { +async function callMulticallOneTargetPackedAndMeasureGas (multiCall, targetAddress, calls) { const data = buildMulticallOneTargetPackedCalldata(targetAddress, calls); const [result, estimatedGas] = await Promise.all([ multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }), @@ -62,8 +62,47 @@ async function callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddres return { decodedArray, estimatedGas: Number(estimatedGas), perCallGas, totalPerCallGas }; } +// Build raw calldata for multicallOneTargetPackedPatchable. Layout: numCalls(2) numCalldatas(2) target(20) then per calldata: header(32) data(N) patchValues(numPatches×32). +// Each call is { baseData, returnWordIndex, patchOffset, patchValues: [bigint|hex,...] }. numCalls = sum of patchValues.length. +function buildMulticallOneTargetPackedPatchableCalldata (targetAddress, calls) { + const selector = ethers.id('multicallOneTargetPackedPatchable()').slice(0, 10); + const numCalls = calls.reduce((s, c) => s + c.patchValues.length, 0); + const numCalldatas = calls.length; + const numCallsBytes = '0x' + numCalls.toString(16).padStart(4, '0'); + const numCalldatasBytes = '0x' + numCalldatas.toString(16).padStart(4, '0'); + const target20 = ethers.zeroPadValue(ethers.getAddress(targetAddress), 20); + + const parts = [selector, numCallsBytes, numCalldatasBytes, target20]; + + for (const { baseData, returnWordIndex, patchOffset, patchValues } of calls) { + const dataLength = ethers.getBytes(baseData).length; + const numPatches = patchValues.length; + const header = (BigInt(returnWordIndex) << 248n) | (BigInt(numPatches) << 232n) | (BigInt(patchOffset) << 216n) | BigInt(dataLength); + parts.push(ethers.toBeHex(header, 32)); + parts.push(baseData); + for (const v of patchValues) { + parts.push(ethers.toBeHex(typeof v === 'bigint' ? v : BigInt(v), 32)); + } + } + + return ethers.concat(parts); +} + +// Call multicallOneTargetPackedPatchable and return decoded results plus gas metrics. calls: array of { baseData, returnWordIndex, patchOffset, patchValues }. +async function callMulticallOneTargetPackedPatchableAndMeasureGas (multiCall, targetAddress, calls) { + const data = buildMulticallOneTargetPackedPatchableCalldata(targetAddress, calls); + const [result, estimatedGas] = await Promise.all([ + multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }), + multiCall.runner.provider.estimateGas({ to: await multiCall.getAddress(), data }), + ]); + const decodedArray = decodeBytesToPackedUint256Array(result); + const perCallGas = decodedArray.map((r) => unpackResult(r).gasUsed); + const totalPerCallGas = perCallGas.reduce((s, g) => s + g, 0); + return { decodedArray, estimatedGas: Number(estimatedGas), perCallGas, totalPerCallGas }; +} + // Call multicallWithGas and return results, gasUsed, estimatedGas, and sum(gasUsed). calls: array of { to: address, data: hexString }. -async function callMulticallWithGasAndMeasureGas(multiCall, calls) { +async function callMulticallWithGasAndMeasureGas (multiCall, calls) { const calldata = multiCall.interface.encodeFunctionData('multicallWithGas', [calls]); const [result, estimatedGas] = await Promise.all([ multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: calldata }), @@ -81,5 +120,7 @@ module.exports = { decodeBytesToPackedUint256Array, buildMulticallOneTargetPackedCalldata, callMulticallOneTargetPackedAndMeasureGas, + buildMulticallOneTargetPackedPatchableCalldata, + callMulticallOneTargetPackedPatchableAndMeasureGas, callMulticallWithGasAndMeasureGas, }; From ac5cd490005a0caf83b929f9f241c3aeec99991a Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Mon, 23 Feb 2026 19:20:02 +0400 Subject: [PATCH 4/9] chore: solhint --- contracts/MultiCall.sol | 4 ++-- test/multicall/MultiCall.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/MultiCall.sol b/contracts/MultiCall.sol index 719d907..827c545 100644 --- a/contracts/MultiCall.sol +++ b/contracts/MultiCall.sol @@ -81,7 +81,7 @@ contract MultiCall { * 227 bits - selected return word (value) */ function multicallOneTargetPacked() external returns (bytes memory) { - assembly { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly if lt(calldatasize(), 26) { revert(0, 0) } @@ -167,7 +167,7 @@ contract MultiCall { * 227 bits - selected return word (value) */ function multicallOneTargetPackedPatchable() external returns (bytes memory) { - assembly { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly if lt(calldatasize(), 28) { revert(0, 0) } diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index 0049b2d..f8fdf2c 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -282,9 +282,9 @@ describe('MultiCall', function () { const multiCallOneTargetPackedResult = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls2); const multicallWithGasResult = await callMulticallWithGasAndMeasureGas(multiCall, calls3); - expect(multicallWithGasResult.estimatedGas).to.be.eq(461_188); - expect(multiCallOneTargetPackedResult.estimatedGas).to.be.eq(178_302); - expect(multiCallOneTargetPackedPatchableResult.estimatedGas).to.be.eq(101_011); + expect(multicallWithGasResult.estimatedGas).to.be.eq(454_751); + expect(multiCallOneTargetPackedResult.estimatedGas).to.be.eq(178_312); + expect(multiCallOneTargetPackedPatchableResult.estimatedGas).to.be.eq(101_012); }); }); }); From 3ee0c2ab1a89d5660e2b0f2dff3eae8953c190aa Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Tue, 24 Feb 2026 10:47:41 +0400 Subject: [PATCH 5/9] feat: add out of range bit to response --- contracts/MultiCall.sol | 40 ++++++++++++++++++++++--------------- test/multicall/MultiCall.js | 4 ++-- test/multicall/utils.js | 11 +++++----- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/contracts/MultiCall.sol b/contracts/MultiCall.sol index 827c545..4b3debf 100644 --- a/contracts/MultiCall.sol +++ b/contracts/MultiCall.sol @@ -77,8 +77,9 @@ contract MultiCall { * @return result ABI-encoded bytes: * For each call (32 bytes per packed word): * 1 bit - success (0 or 1) + * 1 bit - outOfRange (1 if return word > value mask) * 28 bits - gasUsed - * 227 bits - selected return word (value) + * 226 bits - selected return word (value), masked */ function multicallOneTargetPacked() external returns (bytes memory) { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly @@ -101,13 +102,13 @@ contract MultiCall { mstore(add(ptr, 0x20), totalSize) let resultsPtr := add(ptr, 0x40) let endPtr := add(resultsPtr, totalSize) - mstore(0x40, endPtr) let calldataPtr := 26 + let returnWordMask := 0x3ffffffffffffffffffffffffffffffffffffffffffffffffffffffff for { let i := resultsPtr } lt(i, endPtr) { i := add(i, 32) } { let header := calldataload(calldataPtr) - let returnWordIndex := shr(248, header) + let returnWordIndex := byte(0, header) let dataLength := and(header, 0x00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) calldataPtr := add(calldataPtr, 32) @@ -130,10 +131,13 @@ contract MultiCall { let packed := or( or( - shl(255, success), - shl(227, and(gasUsedVal, 0x0fffffff)) + or( + shl(255, success), + shl(254, gt(returnWord, returnWordMask)) // out of range + ), + shl(226, and(gasUsedVal, 0x0fffffff)) ), - and(returnWord, 0x0000000000000007ffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + and(returnWord, returnWordMask) ) mstore(i, packed) @@ -163,8 +167,9 @@ contract MultiCall { * @return result ABI-encoded bytes: * For each call (32 bytes per packed word): * 1 bit - success (0 or 1) + * 1 bit - outOfRange (1 if return word > value mask) * 28 bits - gasUsed - * 227 bits - selected return word (value) + * 226 bits - selected return word (value), masked */ function multicallOneTargetPackedPatchable() external returns (bytes memory) { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly @@ -192,23 +197,22 @@ contract MultiCall { mstore(add(ptr, 0x20), totalSize) let resultsPtr := add(ptr, 0x40) let endPtr := add(resultsPtr, totalSize) - mstore(0x40, endPtr) let resultIdx := resultsPtr let calldataPtr := 28 + let returnWordMask := 0x3ffffffffffffffffffffffffffffffffffffffffffffffffffffffff for { let cdIdx := numCalldatas } cdIdx { cdIdx := sub(cdIdx, 1) } { let header := calldataload(calldataPtr) - let returnWordIndex := shr(248, header) + let returnWordIndex := byte(0, header) let numPatches := and(shr(232, header), 0xffff) let patchOffset := and(shr(216, header), 0xffff) let dataLength := and(header, 0x00000000000000ffffffffffffffffffffffffffffffffffffffffffffffffff) calldataPtr := add(calldataPtr, 32) - let patchesSize := mul(numPatches, 32) let calldataEnd := add(calldataPtr, dataLength) - let patchesEnd := add(calldataEnd, patchesSize) + let patchesEnd := add(calldataEnd, mul(numPatches, 32)) if gt(patchesEnd, calldatasize()) { revert(0, 0) } @@ -216,9 +220,10 @@ contract MultiCall { calldatacopy(endPtr, calldataPtr, dataLength) let offset := mul(returnWordIndex, 32) + let offsetEnd := add(offset, 32) let patchOffsetPtr := add(endPtr, patchOffset) - + for { let j := calldataEnd } lt(j, patchesEnd) { j := add(j, 0x20) } { mstore(patchOffsetPtr, calldataload(j)) @@ -227,17 +232,20 @@ contract MultiCall { let gasUsedVal := sub(g, gas()) let returnWord := 0 - if and(success, iszero(lt(returndatasize(), add(offset, 32)))) { + if and(success, iszero(lt(returndatasize(), offsetEnd))) { returndatacopy(0, offset, 32) returnWord := mload(0) } let packed := or( or( - shl(255, success), - shl(227, and(gasUsedVal, 0x0fffffff)) + or( + shl(255, success), + shl(254, gt(returnWord, returnWordMask)) // out of range + ), + shl(226, and(gasUsedVal, 0x0fffffff)) ), - and(returnWord, 0x0000000000000007ffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + and(returnWord, returnWordMask) ) mstore(resultIdx, packed) resultIdx := add(resultIdx, 32) diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index f8fdf2c..77e3574 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -283,8 +283,8 @@ describe('MultiCall', function () { const multicallWithGasResult = await callMulticallWithGasAndMeasureGas(multiCall, calls3); expect(multicallWithGasResult.estimatedGas).to.be.eq(454_751); - expect(multiCallOneTargetPackedResult.estimatedGas).to.be.eq(178_312); - expect(multiCallOneTargetPackedPatchableResult.estimatedGas).to.be.eq(101_012); + expect(multiCallOneTargetPackedResult.estimatedGas).to.be.eq(180_803); + expect(multiCallOneTargetPackedPatchableResult.estimatedGas).to.be.eq(102_786); }); }); }); diff --git a/test/multicall/utils.js b/test/multicall/utils.js index 5e1764d..db6423a 100644 --- a/test/multicall/utils.js +++ b/test/multicall/utils.js @@ -1,14 +1,15 @@ const { ethers } = require('hardhat'); -// Packed result: bit 255 = success (1 bit), bits 254-227 = gasUsed (28 bits), bits 226-0 = value (227 bits) -const GAS_USED_MASK = (1n << 255n) - (1n << 227n); // bits 254-227 (28 bits) -const VALUE_MASK = (1n << 227n) - 1n; // bits 226-0 +// Packed result: bit 255 = success, bit 254 = outOfRange, bits 253-226 = gasUsed (28 bits), bits 225-0 = value (226 bits) +const GAS_USED_MASK = (1n << 254n) - (1n << 226n); // bits 253-226 (28 bits) +const VALUE_MASK = (1n << 226n) - 1n; // bits 225-0 (226 bits) function unpackResult (r) { const success = ((r >> 255n) & 1n) !== 0n; - const gasUsed = Number((r & GAS_USED_MASK) >> 227n); + const outOfRange = ((r >> 254n) & 1n) !== 0n; + const gasUsed = Number((r & GAS_USED_MASK) >> 226n); const value = r & VALUE_MASK; - return { success, gasUsed, value }; + return { success, outOfRange, gasUsed, value }; } function decodeBytesToPackedUint256Array (callResultHex) { From 984ad3b7fc016a13a1ab1b13fe61585dd50e5e68 Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Tue, 24 Feb 2026 11:11:57 +0400 Subject: [PATCH 6/9] chore: add test for out of range and add fix coverage --- test/multicall/MultiCall.js | 87 ++++++++----------------------------- 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index 77e3574..6a037f5 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -23,15 +23,13 @@ describe('MultiCall', function () { describe('multicallOneTargetPacked', function () { it('returns empty array when numCalls is 0', async function () { - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, []); + const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, []); expect(decodedArray).to.have.lengthOf(0); - expect(estimatedGas).to.be.lte(21656); - expect(estimatedGas - totalPerCallGas).to.be.lte(21656); }); it('single successful call: returnWordIndex 0, parses first 32 bytes', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas( + const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas( multiCall, targetAddress, [{ data: getUintCalldata, returnWordIndex: 0 }], @@ -41,13 +39,11 @@ describe('MultiCall', function () { expect(success).to.equal(true); expect(gasUsed).to.be.gt(0); expect(value).to.equal(42n); - expect(estimatedGas).to.be.lte(25264); - expect(estimatedGas - totalPerCallGas).to.be.lte(22499); }); it('single successful call: returnWordIndex 1, parses second 32 bytes', async function () { const getSeveralWordsCalldata = target.interface.encodeFunctionData('getSeveralWords', [1, 2, 3, 4, 5]); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas( + const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas( multiCall, targetAddress, [{ data: getSeveralWordsCalldata, returnWordIndex: 1 }], @@ -57,13 +53,11 @@ describe('MultiCall', function () { expect(success).to.equal(true); expect(gasUsed).to.be.gt(0); expect(value).to.equal(2n); - expect(estimatedGas).to.be.lte(26124); - expect(estimatedGas - totalPerCallGas).to.be.lte(23244); }); it('failed call: success bit 0, gasUsed set, value 0', async function () { const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas( + const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas( multiCall, targetAddress, [{ data: doRevertCalldata, returnWordIndex: 0 }], @@ -73,14 +67,12 @@ describe('MultiCall', function () { expect(success).to.equal(false); expect(gasUsed).to.be.gt(0); expect(value).to.equal(0n); - expect(estimatedGas).to.be.lte(25283); - expect(estimatedGas - totalPerCallGas).to.be.lte(22472); }); it('multiple calls: mix success and failure', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, [ + const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, [ { data: getUintCalldata, returnWordIndex: 0 }, { data: doRevertCalldata, returnWordIndex: 0 }, { data: getUintCalldata, returnWordIndex: 0 }, @@ -95,36 +87,18 @@ describe('MultiCall', function () { expect(r1.value).to.equal(0n); expect(r2.success).to.equal(true); expect(r2.value).to.equal(42n); - expect(estimatedGas).to.be.lte(26935); - expect(estimatedGas - totalPerCallGas).to.be.lte(23594); - }); - - it('5 calls: all successful', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const calls = Array(5).fill({ data: getUintCalldata, returnWordIndex: 0 }); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls); - expect(decodedArray).to.have.lengthOf(5); - for (let i = 0; i < 5; i++) { - const { success, value } = unpackResult(decodedArray[i]); - expect(success).to.equal(true); - expect(value).to.equal(42n); - } - expect(estimatedGas).to.be.lte(28568); - expect(estimatedGas - totalPerCallGas).to.be.lte(24743); }); it('100 calls: all successful', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const calls = Array(100).fill({ data: getUintCalldata, returnWordIndex: 0 }); - const { decodedArray, estimatedGas, totalPerCallGas } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls); + const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls); expect(decodedArray).to.have.lengthOf(100); for (let i = 0; i < 100; i++) { const { success, value } = unpackResult(decodedArray[i]); expect(success).to.equal(true); expect(value).to.equal(42n); } - expect(estimatedGas).to.be.lte(107122); - expect(estimatedGas - totalPerCallGas).to.be.lte(78122); }); }); @@ -139,16 +113,15 @@ describe('MultiCall', function () { expect(value).to.equal(1n); }); - it('one calldata × 5 patch values at same offset: 5 calls', async function () { + it('return value out of range', async function () { const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); - const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n, 2n, 3n, 4n, 5n] }]; + const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n << 226n] }]; const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); - expect(decodedArray).to.have.lengthOf(5); - for (let i = 0; i < 5; i++) { - const { success, value } = unpackResult(decodedArray[i]); - expect(success).to.equal(true); - expect(value).to.equal(BigInt(i + 1)); - } + expect(decodedArray).to.have.lengthOf(1); + const { success, outOfRange, value } = unpackResult(decodedArray[0]); + expect(success).to.equal(true); + expect(outOfRange).to.equal(true); + expect(value).to.equal(0n); }); it('one calldata × 100 patch values: 100 calls', async function () { @@ -187,33 +160,27 @@ describe('MultiCall', function () { } it('returns empty array when numCalls is 0', async function () { - const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, []); + const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, []); expect(results).to.have.lengthOf(0); expect(gasUsed).to.have.lengthOf(0); - expect(estimatedGas).to.be.lte(22908); - expect(estimatedGas - totalPerCallGas).to.be.lte(22908); }); it('single successful call', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const calls = [toCall(getUintCalldata)]; - const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, calls); expect(results).to.have.lengthOf(1); expect(BigInt(results[0])).to.equal(42n); expect(gasUsed[0]).to.be.gt(0); - expect(estimatedGas).to.be.lte(28509); - expect(estimatedGas - totalPerCallGas).to.be.lte(25205); }); it('failed call', async function () { const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); const calls = [toCall(doRevertCalldata)]; - const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, calls); expect(results).to.have.lengthOf(1); expect(gasUsed[0]).to.be.gt(0); - expect(estimatedGas).to.be.lte(28567); - expect(estimatedGas - totalPerCallGas).to.be.lte(25217); }); it('multiple calls: mix success and failure', async function () { @@ -224,41 +191,25 @@ describe('MultiCall', function () { toCall(doRevertCalldata), toCall(getUintCalldata), ]; - const { results, gasUsed, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, calls); expect(results).to.have.lengthOf(3); expect(BigInt(results[0])).to.equal(42n); expect(gasUsed[1]).to.be.gt(0); expect(BigInt(results[2])).to.equal(42n); - expect(estimatedGas).to.be.lte(34758); - expect(estimatedGas - totalPerCallGas).to.be.lte(29799); - }); - - it('5 calls: all successful', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const calls = Array(5).fill(null).map(() => toCall(getUintCalldata)); - const { results, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); - expect(results).to.have.lengthOf(5); - for (let i = 0; i < 5; i++) { - expect(BigInt(results[i])).to.equal(42n); - } - expect(estimatedGas).to.be.lte(40919); - expect(estimatedGas - totalPerCallGas).to.be.lte(34397); }); it('100 calls: all successful', async function () { const getUintCalldata = target.interface.encodeFunctionData('getUint'); const calls = Array(100).fill(null).map(() => toCall(getUintCalldata)); - const { results, estimatedGas, totalPerCallGas } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const { results } = await callMulticallWithGasAndMeasureGas(multiCall, calls); expect(results).to.have.lengthOf(100); for (let i = 0; i < 100; i++) { expect(BigInt(results[i])).to.equal(42n); } - expect(estimatedGas).to.be.lte(338176); - expect(estimatedGas - totalPerCallGas).to.be.lte(254646); }); }); - describe('performance', function () { + describe.skip('performance', function () { it('getSeveralWords', async function () { const calls = [{ baseData: target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]), From 4cbfcd40d317b48d9f5dc01601d53b813fc8b28f Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Tue, 24 Feb 2026 16:35:08 +0400 Subject: [PATCH 7/9] chore: refactor patchable multicall tests --- test/multicall/MultiCall.js | 102 +++++++++++------- test/multicall/patchable-multicall.js | 144 ++++++++++++++++++++++++++ test/multicall/utils.js | 29 +++++- 3 files changed, 237 insertions(+), 38 deletions(-) create mode 100644 test/multicall/patchable-multicall.js diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index 6a037f5..11d1d1a 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -2,11 +2,14 @@ const { ethers } = require('hardhat'); const { expect } = require('@1inch/solidity-utils'); const { unpackResult, - buildMulticallOneTargetPackedPatchableCalldata, callMulticallOneTargetPackedAndMeasureGas, callMulticallOneTargetPackedPatchableAndMeasureGas, callMulticallWithGasAndMeasureGas, } = require('./utils'); +const { + PatchableCall, + PatchableMulticall, +} = require('./patchable-multicall'); describe('MultiCall', function () { let multiCall; @@ -104,53 +107,82 @@ describe('MultiCall', function () { describe('multicallOneTargetPackedPatchable', function () { it('one calldata × one patch value: one call', async function () { - const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); - const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n] }]; - const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); - expect(decodedArray).to.have.lengthOf(1); - const { success, value } = unpackResult(decodedArray[0]); - expect(success).to.equal(true); - expect(value).to.equal(1n); + const baseDataHex = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const call = PatchableCall.new({ returnWordIndex: 0, patchOffset: 4, baseDataHex, patchValues: [1n] }); + const patchableMulticall = PatchableMulticall.new({ target: targetAddress, calls: [call] }); + const res = await multiCall.runner.provider.call({ + to: await multiCall.getAddress(), + data: patchableMulticall.encode(), + }); + const decodedResults = PatchableMulticall.decode(res); + expect(decodedResults).to.have.lengthOf(1); + expect(decodedResults[0].success).to.equal(true); + expect(decodedResults[0].outOfRange).to.equal(false); + expect(decodedResults[0].value).to.equal(1n); + expect(Number(decodedResults[0].gasUsed)).to.gt(0); }); it('return value out of range', async function () { - const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); - const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n << 226n] }]; - const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); - expect(decodedArray).to.have.lengthOf(1); - const { success, outOfRange, value } = unpackResult(decodedArray[0]); - expect(success).to.equal(true); - expect(outOfRange).to.equal(true); - expect(value).to.equal(0n); + const baseDataHex = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const call = PatchableCall.new({ returnWordIndex: 0, patchOffset: 4, baseDataHex, patchValues: [1n << 226n] }); + const patchableMulticall = PatchableMulticall.new({ target: targetAddress, calls: [call] }); + const res = await multiCall.runner.provider.call({ + to: await multiCall.getAddress(), + data: patchableMulticall.encode(), + }); + const decodedResults = PatchableMulticall.decode(res); + expect(decodedResults).to.have.lengthOf(1); + expect(decodedResults[0].success).to.equal(true); + expect(decodedResults[0].outOfRange).to.equal(true); + expect(decodedResults[0].value).to.equal(0n); }); it('one calldata × 100 patch values: 100 calls', async function () { - const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); - const patchValues = Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n, 0n); - const calls = [{ baseData, returnWordIndex: 0, patchOffset: 4, patchValues }]; - const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); - expect(decodedArray).to.have.lengthOf(100); + const call = PatchableCall.new({ + returnWordIndex: 0, + patchOffset: 4, + baseDataHex: target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]), + patchValues: Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n, 0n), + }); + + const patchableMulticall = PatchableMulticall.new({ + target: targetAddress, + calls: [call], + }); + + const res = await multiCall.runner.provider.call({ + to: await multiCall.getAddress(), + data: patchableMulticall.encode(), + }); + + const decodedResults = PatchableMulticall.decode(res); + + expect(decodedResults).to.have.lengthOf(100); for (let i = 0; i < 100; i++) { - const { success, value } = unpackResult(decodedArray[i]); - expect(success).to.equal(true); - expect(value).to.equal(BigInt(i + 1)); + const decoded = decodedResults[i]; + expect(decoded.success).to.equal(true); + expect(decoded.outOfRange).to.equal(false); + expect(decoded.value).to.equal(BigInt(i + 1)); } }); it('two calldatas × two patch values each: 4 calls', async function () { - const baseData = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const baseDataHex = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); const calls = [ - { baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [1n, 2n] }, - { baseData, returnWordIndex: 0, patchOffset: 4, patchValues: [3n, 4n] }, + PatchableCall.new({ returnWordIndex: 0, patchOffset: 4, baseDataHex, patchValues: [1n, 2n] }), + PatchableCall.new({ returnWordIndex: 0, patchOffset: 4, baseDataHex, patchValues: [3n, 4n] }), ]; - const data = buildMulticallOneTargetPackedPatchableCalldata(targetAddress, calls); - expect(ethers.getBytes(data).length).to.equal(548); // 28 + 2*(32+164+64) - const { decodedArray } = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); - expect(decodedArray).to.have.lengthOf(4); - expect(unpackResult(decodedArray[0]).value).to.equal(1n); - expect(unpackResult(decodedArray[1]).value).to.equal(2n); - expect(unpackResult(decodedArray[2]).value).to.equal(3n); - expect(unpackResult(decodedArray[3]).value).to.equal(4n); + const patchableMulticall = PatchableMulticall.new({ target: targetAddress, calls }); + const res = await multiCall.runner.provider.call({ + to: await multiCall.getAddress(), + data: patchableMulticall.encode(), + }); + const decodedResults = PatchableMulticall.decode(res); + expect(decodedResults).to.have.lengthOf(4); + expect(decodedResults[0].value).to.equal(1n); + expect(decodedResults[1].value).to.equal(2n); + expect(decodedResults[2].value).to.equal(3n); + expect(decodedResults[3].value).to.equal(4n); }); }); diff --git a/test/multicall/patchable-multicall.js b/test/multicall/patchable-multicall.js new file mode 100644 index 0000000..68e3fca --- /dev/null +++ b/test/multicall/patchable-multicall.js @@ -0,0 +1,144 @@ +const { bytesToHex, hexToBytes, toHex } = require('./utils'); + +class PatchableCall { + static DATA_LENGTH_MASK = (1n << 200n) - 1n; + + constructor (returnWordIndex, patchOffset, baseDataHex, patchValues) { + this.returnWordIndex = returnWordIndex; + this.patchOffset = patchOffset; + this.baseDataHex = baseDataHex; + this.patchValues = patchValues; + } + + static new (params) { + return new PatchableCall( + params.returnWordIndex, + params.patchOffset, + params.baseDataHex, + params.patchValues, + ); + } + + get patchValuesCount () { + return this.patchValues.length; + } + + get baseDataBytes () { + const h = this.baseDataHex.startsWith('0x') ? this.baseDataHex.slice(2) : this.baseDataHex; + return Math.floor(h.length / 2); + } + + encode () { + const dataLength = this.baseDataBytes; + const numPatches = this.patchValues.length; + const header = + (BigInt(this.returnWordIndex) << 248n) | + (BigInt(numPatches) << 232n) | + (BigInt(this.patchOffset) << 216n) | + (BigInt(dataLength) & PatchableCall.DATA_LENGTH_MASK); + + let baseHex = this.baseDataHex.startsWith('0x') ? this.baseDataHex.slice(2) : this.baseDataHex; + if (baseHex.length % 2) { + baseHex = '0' + baseHex; + } + + const parts = [toHex(header, 32), '0x' + baseHex]; + for (const v of this.patchValues) { + parts.push(toHex(BigInt(v), 32)); + } + return parts; + } +} + +class PatchableMulticall { + static SELECTOR = '0x7bc97c36'; // keccak256('multicallOneTargetPackedPatchable()').slice(0, 10) + + constructor (target, calls) { + this.target = target; + this.calls = calls; + } + + static new (params) { + return new PatchableMulticall(params.target, params.calls); + } + + static decode (res) { + const bytes = hexToBytes(res); + if (bytes.length < 64) { + return []; + } + const lengthWord = bytes.slice(32, 64); + + let len = 0; + for (let i = 0; i < 32; i++) { + len = (len << 8) | lengthWord[i]; + } + + const data = bytes.slice(64, 64 + len); + const count = Math.floor(data.length / 32); + + const results = []; + for (let i = 0; i < count; i++) { + let word = 0n; + for (let j = 0; j < 32; j++) { + word = (word << 8n) | BigInt(data[i * 32 + j]); + } + results.push(PackedResult.decode(word)); + } + return results; + } + + encode () { + const numCalls = this.calls.reduce((s, e) => s + e.patchValuesCount, 0); + + const chunks = [ + hexToBytes(PatchableMulticall.SELECTOR), + hexToBytes(toHex(numCalls, 2)), + hexToBytes(toHex(this.calls.length, 2)), + hexToBytes(this.target.replace('0x', '')), + ]; + + for (const entry of this.calls) { + const parts = entry.encode(); + for (const p of parts) { + chunks.push(hexToBytes(p)); + } + } + + const total = chunks.reduce((s, c) => s + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return bytesToHex(out); + } +} + +class PackedResult { + static GAS_USED_MASK = (1n << 254n) - (1n << 226n); + static VALUE_MASK = (1n << 226n) - 1n; + + constructor (success, outOfRange, gasUsed, value) { + this.success = success; + this.outOfRange = outOfRange; + this.gasUsed = gasUsed; + this.value = value; + } + + static decode (packed) { + const r = BigInt(packed); + return new PackedResult( + ((r >> 255n) & 1n) !== 0n, + ((r >> 254n) & 1n) !== 0n, + (r & PackedResult.GAS_USED_MASK) >> 226n, + r & PackedResult.VALUE_MASK, + ); + } +} + +module.exports = { + PatchableMulticall, + PatchableCall, +}; diff --git a/test/multicall/utils.js b/test/multicall/utils.js index db6423a..c8808eb 100644 --- a/test/multicall/utils.js +++ b/test/multicall/utils.js @@ -114,14 +114,37 @@ async function callMulticallWithGasAndMeasureGas (multiCall, calls) { return { results, gasUsed: gasUsed.map(Number), estimatedGas: Number(estimatedGas), totalPerCallGas }; } +function toHex (n, byteLength) { + const hex = BigInt(n).toString(16); + return '0x' + hex.padStart(byteLength * 2, '0').slice(-byteLength * 2); +} + +function hexToBytes (hex) { + const h = hex.startsWith('0x') ? hex.slice(2) : hex; + const len = h.length / 2; + const out = new Uint8Array(len); + for (let i = 0; i < len; i++) { + out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function bytesToHex (bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += bytes[i].toString(16).padStart(2, '0'); + } + return '0x' + s; +} + module.exports = { GAS_USED_MASK, VALUE_MASK, unpackResult, - decodeBytesToPackedUint256Array, - buildMulticallOneTargetPackedCalldata, callMulticallOneTargetPackedAndMeasureGas, - buildMulticallOneTargetPackedPatchableCalldata, callMulticallOneTargetPackedPatchableAndMeasureGas, callMulticallWithGasAndMeasureGas, + toHex, + hexToBytes, + bytesToHex, }; From 7abd624af0f6cdc6f9dd275bbd28dc0da5837fdc Mon Sep 17 00:00:00 2001 From: Kirill Kuznetcov Date: Tue, 24 Feb 2026 17:08:44 +0400 Subject: [PATCH 8/9] chore: refactor tests --- test/multicall/MultiCall.js | 265 ++++++++++++++----------- test/multicall/one-target-multicall.js | 119 +++++++++++ test/multicall/patchable-multicall.js | 1 + test/multicall/utils.js | 122 ------------ 4 files changed, 268 insertions(+), 239 deletions(-) create mode 100644 test/multicall/one-target-multicall.js diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index 11d1d1a..8139cd2 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -1,15 +1,7 @@ const { ethers } = require('hardhat'); const { expect } = require('@1inch/solidity-utils'); -const { - unpackResult, - callMulticallOneTargetPackedAndMeasureGas, - callMulticallOneTargetPackedPatchableAndMeasureGas, - callMulticallWithGasAndMeasureGas, -} = require('./utils'); -const { - PatchableCall, - PatchableMulticall, -} = require('./patchable-multicall'); +const { OneTargetPackedCall, OneTargetPackedMulticall } = require('./one-target-multicall'); +const { PatchableCall, PatchableMulticall } = require('./patchable-multicall'); describe('MultiCall', function () { let multiCall; @@ -26,81 +18,86 @@ describe('MultiCall', function () { describe('multicallOneTargetPacked', function () { it('returns empty array when numCalls is 0', async function () { - const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, []); - expect(decodedArray).to.have.lengthOf(0); + const m = OneTargetPackedMulticall.new({ target: targetAddress, calls: [] }); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: m.encode() }); + const decoded = OneTargetPackedMulticall.decode(res); + expect(decoded).to.have.lengthOf(0); }); it('single successful call: returnWordIndex 0, parses first 32 bytes', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas( - multiCall, - targetAddress, - [{ data: getUintCalldata, returnWordIndex: 0 }], - ); - expect(decodedArray).to.have.lengthOf(1); - const { success, gasUsed, value } = unpackResult(decodedArray[0]); - expect(success).to.equal(true); - expect(gasUsed).to.be.gt(0); - expect(value).to.equal(42n); + const data = target.interface.encodeFunctionData('getUint'); + const m = OneTargetPackedMulticall.new({ + target: targetAddress, + calls: [OneTargetPackedCall.new({ data, returnWordIndex: 0 })], + }); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: m.encode() }); + const decoded = OneTargetPackedMulticall.decode(res); + expect(decoded).to.have.lengthOf(1); + expect(decoded[0].success).to.equal(true); + expect(Number(decoded[0].gasUsed)).to.be.gt(0); + expect(decoded[0].value).to.equal(42n); }); it('single successful call: returnWordIndex 1, parses second 32 bytes', async function () { - const getSeveralWordsCalldata = target.interface.encodeFunctionData('getSeveralWords', [1, 2, 3, 4, 5]); - const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas( - multiCall, - targetAddress, - [{ data: getSeveralWordsCalldata, returnWordIndex: 1 }], - ); - expect(decodedArray).to.have.lengthOf(1); - const { success, gasUsed, value } = unpackResult(decodedArray[0]); - expect(success).to.equal(true); - expect(gasUsed).to.be.gt(0); - expect(value).to.equal(2n); + const data = target.interface.encodeFunctionData('getSeveralWords', [1, 2, 3, 4, 5]); + const m = OneTargetPackedMulticall.new({ + target: targetAddress, + calls: [OneTargetPackedCall.new({ data, returnWordIndex: 1 })], + }); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: m.encode() }); + const decoded = OneTargetPackedMulticall.decode(res); + expect(decoded).to.have.lengthOf(1); + expect(decoded[0].success).to.equal(true); + expect(Number(decoded[0].gasUsed)).to.be.gt(0); + expect(decoded[0].value).to.equal(2n); }); it('failed call: success bit 0, gasUsed set, value 0', async function () { - const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas( - multiCall, - targetAddress, - [{ data: doRevertCalldata, returnWordIndex: 0 }], - ); - expect(decodedArray).to.have.lengthOf(1); - const { success, gasUsed, value } = unpackResult(decodedArray[0]); - expect(success).to.equal(false); - expect(gasUsed).to.be.gt(0); - expect(value).to.equal(0n); + const data = target.interface.encodeFunctionData('doRevert'); + const m = OneTargetPackedMulticall.new({ + target: targetAddress, + calls: [OneTargetPackedCall.new({ data, returnWordIndex: 0 })], + }); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: m.encode() }); + const decoded = OneTargetPackedMulticall.decode(res); + expect(decoded).to.have.lengthOf(1); + expect(decoded[0].success).to.equal(false); + expect(Number(decoded[0].gasUsed)).to.be.gt(0); + expect(decoded[0].value).to.equal(0n); }); it('multiple calls: mix success and failure', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, [ - { data: getUintCalldata, returnWordIndex: 0 }, - { data: doRevertCalldata, returnWordIndex: 0 }, - { data: getUintCalldata, returnWordIndex: 0 }, - ]); - expect(decodedArray).to.have.lengthOf(3); - const r0 = unpackResult(decodedArray[0]); - const r1 = unpackResult(decodedArray[1]); - const r2 = unpackResult(decodedArray[2]); - expect(r0.success).to.equal(true); - expect(r0.value).to.equal(42n); - expect(r1.success).to.equal(false); - expect(r1.value).to.equal(0n); - expect(r2.success).to.equal(true); - expect(r2.value).to.equal(42n); + const getUintData = target.interface.encodeFunctionData('getUint'); + const doRevertData = target.interface.encodeFunctionData('doRevert'); + const m = OneTargetPackedMulticall.new({ + target: targetAddress, + calls: [ + OneTargetPackedCall.new({ data: getUintData, returnWordIndex: 0 }), + OneTargetPackedCall.new({ data: doRevertData, returnWordIndex: 0 }), + OneTargetPackedCall.new({ data: getUintData, returnWordIndex: 0 }), + ], + }); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: m.encode() }); + const decoded = OneTargetPackedMulticall.decode(res); + expect(decoded).to.have.lengthOf(3); + expect(decoded[0].success).to.equal(true); + expect(decoded[0].value).to.equal(42n); + expect(decoded[1].success).to.equal(false); + expect(decoded[1].value).to.equal(0n); + expect(decoded[2].success).to.equal(true); + expect(decoded[2].value).to.equal(42n); }); it('100 calls: all successful', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const calls = Array(100).fill({ data: getUintCalldata, returnWordIndex: 0 }); - const { decodedArray } = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls); - expect(decodedArray).to.have.lengthOf(100); + const data = target.interface.encodeFunctionData('getUint'); + const calls = Array(100).fill(null).map(() => OneTargetPackedCall.new({ data, returnWordIndex: 0 })); + const m = OneTargetPackedMulticall.new({ target: targetAddress, calls }); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: m.encode() }); + const decoded = OneTargetPackedMulticall.decode(res); + expect(decoded).to.have.lengthOf(100); for (let i = 0; i < 100; i++) { - const { success, value } = unpackResult(decodedArray[i]); - expect(success).to.equal(true); - expect(value).to.equal(42n); + expect(decoded[i].success).to.equal(true); + expect(decoded[i].value).to.equal(42n); } }); }); @@ -187,53 +184,73 @@ describe('MultiCall', function () { }); describe('multicallWithGas', function () { - function toCall (data) { - return { to: targetAddress, data }; - } - it('returns empty array when numCalls is 0', async function () { - const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, []); + const data = multiCall.interface.encodeFunctionData('multicallWithGas', [[]]); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }); + const [results, gasUsed] = multiCall.interface.decodeFunctionResult('multicallWithGas', res); expect(results).to.have.lengthOf(0); expect(gasUsed).to.have.lengthOf(0); }); it('single successful call', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - - const calls = [toCall(getUintCalldata)]; - const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const data = multiCall.interface.encodeFunctionData('multicallWithGas', [[{ + to: targetAddress, + data: target.interface.encodeFunctionData('getUint'), + }]]); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }); + const [results, gasUsed] = multiCall.interface.decodeFunctionResult('multicallWithGas', res); expect(results).to.have.lengthOf(1); expect(BigInt(results[0])).to.equal(42n); - expect(gasUsed[0]).to.be.gt(0); + expect(Number(gasUsed[0])).to.be.gt(0); }); it('failed call', async function () { - const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const calls = [toCall(doRevertCalldata)]; - const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const data = multiCall.interface.encodeFunctionData('multicallWithGas', [[{ + to: targetAddress, + data: target.interface.encodeFunctionData('doRevert'), + }]]); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }); + const [results, gasUsed] = multiCall.interface.decodeFunctionResult('multicallWithGas', res); expect(results).to.have.lengthOf(1); - expect(gasUsed[0]).to.be.gt(0); + expect(Number(gasUsed[0])).to.be.gt(0); }); it('multiple calls: mix success and failure', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const doRevertCalldata = target.interface.encodeFunctionData('doRevert'); - const calls = [ - toCall(getUintCalldata), - toCall(doRevertCalldata), - toCall(getUintCalldata), - ]; - const { results, gasUsed } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const getUintData = target.interface.encodeFunctionData('getUint'); + const doRevertData = target.interface.encodeFunctionData('doRevert'); + + const data = multiCall.interface.encodeFunctionData('multicallWithGas', [[ + { + to: targetAddress, + data: getUintData, + }, + { + to: targetAddress, + data: doRevertData, + }, + { + to: targetAddress, + data: getUintData, + }, + ]]); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }); + const [results, gasUsed] = multiCall.interface.decodeFunctionResult('multicallWithGas', res); + expect(results).to.have.lengthOf(3); expect(BigInt(results[0])).to.equal(42n); - expect(gasUsed[1]).to.be.gt(0); + expect(Number(gasUsed[1])).to.be.gt(0); expect(BigInt(results[2])).to.equal(42n); }); it('100 calls: all successful', async function () { - const getUintCalldata = target.interface.encodeFunctionData('getUint'); - const calls = Array(100).fill(null).map(() => toCall(getUintCalldata)); - const { results } = await callMulticallWithGasAndMeasureGas(multiCall, calls); + const getUintData = target.interface.encodeFunctionData('getUint'); + const calls = Array.from({ length: 100 }).map(() => ({ + to: targetAddress, + data: getUintData, + })); + const data = multiCall.interface.encodeFunctionData('multicallWithGas', [calls]); + const res = await multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }); + const [results] = multiCall.interface.decodeFunctionResult('multicallWithGas', res); expect(results).to.have.lengthOf(100); for (let i = 0; i < 100; i++) { expect(BigInt(results[i])).to.equal(42n); @@ -243,31 +260,45 @@ describe('MultiCall', function () { describe.skip('performance', function () { it('getSeveralWords', async function () { - const calls = [{ - baseData: target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]), - returnWordIndex: 0, - patchOffset: 4, - patchValues: Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n), - }, + const baseDataHex = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const patchableCalls = [ + PatchableCall.new({ + returnWordIndex: 0, + patchOffset: 4, + baseDataHex, + patchValues: Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n), + }), ]; + const packedCalls = Array.from({ length: 100 }, (_, i) => + OneTargetPackedCall.new({ + data: target.interface.encodeFunctionData('getSeveralWords', [BigInt(i) + 1n, 0, 0, 0, 0]), + returnWordIndex: 0, + }), + ); + const withGasCalls = Array.from({ length: 100 }, (_, i) => ( + { + to: targetAddress, + data: target.interface.encodeFunctionData('getSeveralWords', [BigInt(i) + 1n, 0, 0, 0, 0]), + } + )); - const calls2 = Array.from({ length: 100 }, (_, i) => ({ - data: target.interface.encodeFunctionData('getSeveralWords', [BigInt(i) + 1n, 0, 0, 0, 0]), - returnWordIndex: 0, - })); - - const calls3 = Array.from({ length: 100 }, (_, i) => ({ - data: target.interface.encodeFunctionData('getSeveralWords', [BigInt(i) + 1n, 0, 0, 0, 0]), - to: targetAddress, - })); - - const multiCallOneTargetPackedPatchableResult = await callMulticallOneTargetPackedPatchableAndMeasureGas(multiCall, targetAddress, calls); - const multiCallOneTargetPackedResult = await callMulticallOneTargetPackedAndMeasureGas(multiCall, targetAddress, calls2); - const multicallWithGasResult = await callMulticallWithGasAndMeasureGas(multiCall, calls3); + const multiCallAddress = await multiCall.getAddress(); + const patchableGas = await multiCall.runner.provider.estimateGas({ + to: multiCallAddress, + data: PatchableMulticall.new({ target: targetAddress, calls: patchableCalls }).encode(), + }); + const packedGas = await multiCall.runner.provider.estimateGas({ + to: multiCallAddress, + data: OneTargetPackedMulticall.new({ target: targetAddress, calls: packedCalls }).encode(), + }); + const withGasGas = await multiCall.runner.provider.estimateGas({ + to: multiCallAddress, + data: multiCall.interface.encodeFunctionData('multicallWithGas', [withGasCalls]), + }); - expect(multicallWithGasResult.estimatedGas).to.be.eq(454_751); - expect(multiCallOneTargetPackedResult.estimatedGas).to.be.eq(180_803); - expect(multiCallOneTargetPackedPatchableResult.estimatedGas).to.be.eq(102_786); + expect(Number(withGasGas)).to.be.eq(454_751); + expect(Number(packedGas)).to.be.eq(180_803); + expect(Number(patchableGas)).to.be.eq(102_786); }); }); }); diff --git a/test/multicall/one-target-multicall.js b/test/multicall/one-target-multicall.js new file mode 100644 index 0000000..be644ee --- /dev/null +++ b/test/multicall/one-target-multicall.js @@ -0,0 +1,119 @@ +const { bytesToHex, hexToBytes, toHex } = require('./utils'); + +class OneTargetPackedCall { + constructor (returnWordIndex, data) { + this.returnWordIndex = returnWordIndex; + this.data = data; + } + + static new (params) { + return new OneTargetPackedCall(params.returnWordIndex, params.data); + } + + get dataBytes () { + const h = this.data.startsWith('0x') ? this.data.slice(2) : this.data; + return Math.floor(h.length / 2); + } + + encode () { + const dataLength = this.dataBytes; + const header = (BigInt(this.returnWordIndex) << 248n) | BigInt(dataLength); + let data = this.data.startsWith('0x') ? this.data.slice(2) : this.data; + if (data.length % 2) { + data = '0' + data; + } + return [toHex(header, 32), '0x' + data]; + } +} + +class OneTargetPackedMulticall { + static SELECTOR = '0x27ae9ae3'; // keccak256('multicallOneTargetPacked()').slice(0,10) + + constructor (target, calls) { + this.target = target; + this.calls = calls; + } + + static new (params) { + return new OneTargetPackedMulticall(params.target, params.calls); + } + + static decode (res) { + const bytes = hexToBytes(res); + if (bytes.length < 64) { + return []; + } + + const lengthWord = bytes.slice(32, 64); + let len = 0; + for (let i = 0; i < 32; i++) { + len = (len << 8) | lengthWord[i]; + } + + const data = bytes.slice(64, 64 + len); + const count = Math.floor(data.length / 32); + + const results = []; + for (let i = 0; i < count; i++) { + let word = 0n; + for (let j = 0; j < 32; j++) { + word = (word << 8n) | BigInt(data[i * 32 + j]); + } + results.push(PackedResult.decode(word)); + } + + return results; + } + + encode () { + const chunks = [ + hexToBytes(OneTargetPackedMulticall.SELECTOR), + hexToBytes(toHex(this.calls.length, 2)), + hexToBytes(this.target.replace(/^0x/, '').toLowerCase().padStart(40, '0')), + ]; + + for (const call of this.calls) { + for (const chunk of call.encode()) { + chunks.push(hexToBytes(chunk)); + } + } + + const total = chunks.reduce((s, c) => s + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + + return bytesToHex(out); + } +} + +class PackedResult { + static GAS_USED_MASK = (1n << 254n) - (1n << 226n); + static VALUE_MASK = (1n << 226n) - 1n; + + constructor (success, outOfRange, gasUsed, value) { + this.success = success; + this.outOfRange = outOfRange; + this.gasUsed = gasUsed; + this.value = value; + } + + static decode (packed) { + const r = BigInt(packed); + return new PackedResult( + ((r >> 255n) & 1n) !== 0n, + ((r >> 254n) & 1n) !== 0n, + (r & PackedResult.GAS_USED_MASK) >> 226n, + r & PackedResult.VALUE_MASK, + ); + } +} + +module.exports = { + OneTargetPackedMulticall, + OneTargetPackedCall, + PackedResult, +}; diff --git a/test/multicall/patchable-multicall.js b/test/multicall/patchable-multicall.js index 68e3fca..ba2a8f9 100644 --- a/test/multicall/patchable-multicall.js +++ b/test/multicall/patchable-multicall.js @@ -141,4 +141,5 @@ class PackedResult { module.exports = { PatchableMulticall, PatchableCall, + PackedResult, }; diff --git a/test/multicall/utils.js b/test/multicall/utils.js index c8808eb..beb118d 100644 --- a/test/multicall/utils.js +++ b/test/multicall/utils.js @@ -1,119 +1,3 @@ -const { ethers } = require('hardhat'); - -// Packed result: bit 255 = success, bit 254 = outOfRange, bits 253-226 = gasUsed (28 bits), bits 225-0 = value (226 bits) -const GAS_USED_MASK = (1n << 254n) - (1n << 226n); // bits 253-226 (28 bits) -const VALUE_MASK = (1n << 226n) - 1n; // bits 225-0 (226 bits) - -function unpackResult (r) { - const success = ((r >> 255n) & 1n) !== 0n; - const outOfRange = ((r >> 254n) & 1n) !== 0n; - const gasUsed = Number((r & GAS_USED_MASK) >> 226n); - const value = r & VALUE_MASK; - return { success, outOfRange, gasUsed, value }; -} - -function decodeBytesToPackedUint256Array (callResultHex) { - if (!callResultHex || callResultHex === '0x') return []; - const bytesHex = ethers.AbiCoder.defaultAbiCoder().decode(['bytes'], callResultHex)[0]; - if (!bytesHex || bytesHex === '0x') return []; - const data = ethers.getBytes(bytesHex); - const count = Math.floor(data.length / 32); - const arr = []; - for (let i = 0; i < count; i++) { - const chunk = data.slice(i * 32, (i + 1) * 32); - arr.push(chunk.length === 0 ? 0n : ethers.toBigInt(ethers.hexlify(chunk))); - } - return arr; -} - -// Build raw calldata for multicallOneTargetPacked: selector + numCalls(2) + target(20) + [header(32) + data]* -// Header = 32-byte word: highest byte = returnWordIndex, lower 31 bytes = dataLength. Each call is { data: hexString, returnWordIndex: number }. -function buildMulticallOneTargetPackedCalldata (targetAddress, calls) { - const selector = ethers.id('multicallOneTargetPacked()').slice(0, 10); - const numCallsBytes = '0x' + calls.length.toString(16).padStart(4, '0'); - const target20 = ethers.zeroPadValue(ethers.getAddress(targetAddress), 20); - - const parts = [ - selector, - numCallsBytes, - target20, - ]; - - for (const { data: callData, returnWordIndex } of calls) { - const lenBytes = ethers.getBytes(callData).length; - const header = (BigInt(returnWordIndex) << 248n) | BigInt(lenBytes); - parts.push(ethers.toBeHex(header, 32)); - parts.push(callData); - } - - return ethers.concat(parts); -} - -// Call multicallOneTargetPacked and return decoded results plus gas metrics (estimated gas, per-call gas from packed results). -// calls: array of { data: hexString, returnWordIndex: number }. -async function callMulticallOneTargetPackedAndMeasureGas (multiCall, targetAddress, calls) { - const data = buildMulticallOneTargetPackedCalldata(targetAddress, calls); - const [result, estimatedGas] = await Promise.all([ - multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }), - multiCall.runner.provider.estimateGas({ to: await multiCall.getAddress(), data }), - ]); - const decodedArray = decodeBytesToPackedUint256Array(result); - const perCallGas = decodedArray.map((r) => unpackResult(r).gasUsed); - const totalPerCallGas = perCallGas.reduce((s, g) => s + g, 0); - return { decodedArray, estimatedGas: Number(estimatedGas), perCallGas, totalPerCallGas }; -} - -// Build raw calldata for multicallOneTargetPackedPatchable. Layout: numCalls(2) numCalldatas(2) target(20) then per calldata: header(32) data(N) patchValues(numPatches×32). -// Each call is { baseData, returnWordIndex, patchOffset, patchValues: [bigint|hex,...] }. numCalls = sum of patchValues.length. -function buildMulticallOneTargetPackedPatchableCalldata (targetAddress, calls) { - const selector = ethers.id('multicallOneTargetPackedPatchable()').slice(0, 10); - const numCalls = calls.reduce((s, c) => s + c.patchValues.length, 0); - const numCalldatas = calls.length; - const numCallsBytes = '0x' + numCalls.toString(16).padStart(4, '0'); - const numCalldatasBytes = '0x' + numCalldatas.toString(16).padStart(4, '0'); - const target20 = ethers.zeroPadValue(ethers.getAddress(targetAddress), 20); - - const parts = [selector, numCallsBytes, numCalldatasBytes, target20]; - - for (const { baseData, returnWordIndex, patchOffset, patchValues } of calls) { - const dataLength = ethers.getBytes(baseData).length; - const numPatches = patchValues.length; - const header = (BigInt(returnWordIndex) << 248n) | (BigInt(numPatches) << 232n) | (BigInt(patchOffset) << 216n) | BigInt(dataLength); - parts.push(ethers.toBeHex(header, 32)); - parts.push(baseData); - for (const v of patchValues) { - parts.push(ethers.toBeHex(typeof v === 'bigint' ? v : BigInt(v), 32)); - } - } - - return ethers.concat(parts); -} - -// Call multicallOneTargetPackedPatchable and return decoded results plus gas metrics. calls: array of { baseData, returnWordIndex, patchOffset, patchValues }. -async function callMulticallOneTargetPackedPatchableAndMeasureGas (multiCall, targetAddress, calls) { - const data = buildMulticallOneTargetPackedPatchableCalldata(targetAddress, calls); - const [result, estimatedGas] = await Promise.all([ - multiCall.runner.provider.call({ to: await multiCall.getAddress(), data }), - multiCall.runner.provider.estimateGas({ to: await multiCall.getAddress(), data }), - ]); - const decodedArray = decodeBytesToPackedUint256Array(result); - const perCallGas = decodedArray.map((r) => unpackResult(r).gasUsed); - const totalPerCallGas = perCallGas.reduce((s, g) => s + g, 0); - return { decodedArray, estimatedGas: Number(estimatedGas), perCallGas, totalPerCallGas }; -} - -// Call multicallWithGas and return results, gasUsed, estimatedGas, and sum(gasUsed). calls: array of { to: address, data: hexString }. -async function callMulticallWithGasAndMeasureGas (multiCall, calls) { - const calldata = multiCall.interface.encodeFunctionData('multicallWithGas', [calls]); - const [result, estimatedGas] = await Promise.all([ - multiCall.runner.provider.call({ to: await multiCall.getAddress(), data: calldata }), - multiCall.runner.provider.estimateGas({ to: await multiCall.getAddress(), data: calldata }), - ]); - const [results, gasUsed] = multiCall.interface.decodeFunctionResult('multicallWithGas', result); - const totalPerCallGas = gasUsed.reduce((s, g) => s + Number(g), 0); - return { results, gasUsed: gasUsed.map(Number), estimatedGas: Number(estimatedGas), totalPerCallGas }; -} - function toHex (n, byteLength) { const hex = BigInt(n).toString(16); return '0x' + hex.padStart(byteLength * 2, '0').slice(-byteLength * 2); @@ -138,12 +22,6 @@ function bytesToHex (bytes) { } module.exports = { - GAS_USED_MASK, - VALUE_MASK, - unpackResult, - callMulticallOneTargetPackedAndMeasureGas, - callMulticallOneTargetPackedPatchableAndMeasureGas, - callMulticallWithGasAndMeasureGas, toHex, hexToBytes, bytesToHex, From 3bb7116ee84621cccfe57dc6d1aff340eda7d242 Mon Sep 17 00:00:00 2001 From: Kirill Date: Tue, 31 Mar 2026 17:27:19 +0400 Subject: [PATCH 9/9] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- contracts/MultiCall.sol | 4 ++-- contracts/mocks/MultiCallTestTarget.sol | 4 ++-- test/multicall/MultiCall.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/MultiCall.sol b/contracts/MultiCall.sol index 4b3debf..4670b34 100644 --- a/contracts/MultiCall.sol +++ b/contracts/MultiCall.sol @@ -71,7 +71,7 @@ contract MultiCall { * 2 bytes - numCalls * 20 bytes - target address * For each call: - * 32 bytes - header (1 byte returnWordIndex | 31 bytes dataLength) + * 32 bytes - header (1 byte returnWordIndex | 248 bits (31 bytes) dataLength) * N bytes - call data (length = dataLength) * * @return result ABI-encoded bytes: @@ -213,7 +213,7 @@ contract MultiCall { let calldataEnd := add(calldataPtr, dataLength) let patchesEnd := add(calldataEnd, mul(numPatches, 32)) - if gt(patchesEnd, calldatasize()) { + if gt(patchesEnd, calldatasize()) { revert(0, 0) } diff --git a/contracts/mocks/MultiCallTestTarget.sol b/contracts/mocks/MultiCallTestTarget.sol index 1873af7..0f9f415 100644 --- a/contracts/mocks/MultiCallTestTarget.sol +++ b/contracts/mocks/MultiCallTestTarget.sol @@ -8,7 +8,7 @@ contract MultiCallTestTarget { return 42; } - function getSeveralWords(uint256 x, uint256 y, uint256 z, uint256 w, uint256 v) external view returns (uint256 a, uint256 b, uint256 c, uint256 d, uint256 e) { + function getSeveralWords(uint256 x, uint256 y, uint256 z, uint256 w, uint256 v) external pure returns (uint256 a, uint256 b, uint256 c, uint256 d, uint256 e) { a = x; b = y; c = z; @@ -16,7 +16,7 @@ contract MultiCallTestTarget { e = v; } - function doRevert() external view { + function doRevert() external pure { revert TestRevert(); } } diff --git a/test/multicall/MultiCall.js b/test/multicall/MultiCall.js index 8139cd2..8bbe9a6 100644 --- a/test/multicall/MultiCall.js +++ b/test/multicall/MultiCall.js @@ -139,7 +139,7 @@ describe('MultiCall', function () { returnWordIndex: 0, patchOffset: 4, baseDataHex: target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]), - patchValues: Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n, 0n), + patchValues: Array.from({ length: 100 }, (_, i) => BigInt(i) + 1n), }); const patchableMulticall = PatchableMulticall.new({