diff --git a/contracts/MultiCall.sol b/contracts/MultiCall.sol index 7b8ceac..4670b34 100644 --- a/contracts/MultiCall.sol +++ b/contracts/MultiCall.sol @@ -62,6 +62,202 @@ 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 | 248 bits (31 bytes) dataLength) + * N bytes - call data (length = dataLength) + * + * @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 + * 226 bits - selected return word (value), masked + */ + function multicallOneTargetPacked() external returns (bytes memory) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + if lt(calldatasize(), 26) { + revert(0, 0) + } + + let numCalls := shr(240, calldataload(4)) + let target := shr(96, calldataload(6)) + + 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) + + let calldataPtr := 26 + let returnWordMask := 0x3ffffffffffffffffffffffffffffffffffffffffffffffffffffffff + + for { let i := resultsPtr } lt(i, endPtr) { i := add(i, 32) } { + let header := calldataload(calldataPtr) + let returnWordIndex := byte(0, 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( + or( + shl(255, success), + shl(254, gt(returnWord, returnWordMask)) // out of range + ), + shl(226, and(gasUsedVal, 0x0fffffff)) + ), + and(returnWord, returnWordMask) + ) + mstore(i, packed) + + calldataPtr := add(calldataPtr, dataLength) + } + + 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) + * 1 bit - outOfRange (1 if return word > value mask) + * 28 bits - gasUsed + * 226 bits - selected return word (value), masked + */ + function multicallOneTargetPackedPatchable() external returns (bytes memory) { + assembly ("memory-safe") { // solhint-disable-line no-inline-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) + + let resultIdx := resultsPtr + + let calldataPtr := 28 + let returnWordMask := 0x3ffffffffffffffffffffffffffffffffffffffffffffffffffffffff + + for { let cdIdx := numCalldatas } cdIdx { cdIdx := sub(cdIdx, 1) } { + let header := calldataload(calldataPtr) + 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 calldataEnd := add(calldataPtr, dataLength) + let patchesEnd := add(calldataEnd, mul(numPatches, 32)) + if gt(patchesEnd, calldatasize()) { + revert(0, 0) + } + + 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)) + + 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(), offsetEnd))) { + returndatacopy(0, offset, 32) + returnWord := mload(0) + } + + let packed := or( + or( + or( + shl(255, success), + shl(254, gt(returnWord, returnWordMask)) // out of range + ), + shl(226, and(gasUsedVal, 0x0fffffff)) + ), + and(returnWord, returnWordMask) + ) + mstore(resultIdx, packed) + resultIdx := add(resultIdx, 32) + } + + calldataPtr := patchesEnd + } + + return(ptr, add(totalSize, 0x40)) + } + } + /// @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..0f9f415 --- /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 pure returns (uint256 a, uint256 b, uint256 c, uint256 d, uint256 e) { + a = x; + b = y; + c = z; + d = w; + e = v; + } + + function doRevert() external pure { + 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..8bbe9a6 --- /dev/null +++ b/test/multicall/MultiCall.js @@ -0,0 +1,304 @@ +const { ethers } = require('hardhat'); +const { expect } = require('@1inch/solidity-utils'); +const { OneTargetPackedCall, OneTargetPackedMulticall } = require('./one-target-multicall'); +const { PatchableCall, PatchableMulticall } = require('./patchable-multicall'); + +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 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 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 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 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 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 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++) { + expect(decoded[i].success).to.equal(true); + expect(decoded[i].value).to.equal(42n); + } + }); + }); + + describe('multicallOneTargetPackedPatchable', function () { + it('one calldata × one patch value: one call', async function () { + 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 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 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), + }); + + 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 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 baseDataHex = target.interface.encodeFunctionData('getSeveralWords', [0, 0, 0, 0, 0]); + const calls = [ + PatchableCall.new({ returnWordIndex: 0, patchOffset: 4, baseDataHex, patchValues: [1n, 2n] }), + PatchableCall.new({ returnWordIndex: 0, patchOffset: 4, baseDataHex, patchValues: [3n, 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); + }); + }); + + describe('multicallWithGas', function () { + it('returns empty array when numCalls is 0', async function () { + 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 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(Number(gasUsed[0])).to.be.gt(0); + }); + + it('failed call', async function () { + 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(Number(gasUsed[0])).to.be.gt(0); + }); + + it('multiple calls: mix success and failure', async function () { + 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(Number(gasUsed[1])).to.be.gt(0); + expect(BigInt(results[2])).to.equal(42n); + }); + + it('100 calls: all successful', async function () { + 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); + } + }); + }); + + describe.skip('performance', function () { + it('getSeveralWords', async function () { + 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 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(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 new file mode 100644 index 0000000..ba2a8f9 --- /dev/null +++ b/test/multicall/patchable-multicall.js @@ -0,0 +1,145 @@ +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, + PackedResult, +}; diff --git a/test/multicall/utils.js b/test/multicall/utils.js new file mode 100644 index 0000000..beb118d --- /dev/null +++ b/test/multicall/utils.js @@ -0,0 +1,28 @@ +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 = { + toHex, + hexToBytes, + bytesToHex, +};