diff --git a/readme.md b/readme.md index 4135a2c9..7df571d1 100644 --- a/readme.md +++ b/readme.md @@ -27,12 +27,12 @@ Measured with esbuild. Smaller is better. -| What you import | essential-eth@1.1.0 | ethers@6.16.0 | viem@2.46.0 | web3@4.16.0 | ox@0.12.1 | -| ---------------------------------------- | :-----------------: | :-----------: | :---------: | :---------: | :------------: | -| **Full library** | **43.1 kB** 🏆 | 394.0 kB | 385.3 kB | 495.8 kB | 612.8 kB | -| **Provider** (getBalance, getBlock, etc) | 30.8 kB | 260.0 kB | 306.5 kB | 454.5 kB | **10.9 kB** 🏆 | -| **Contract** (read-only calls) | **24.8 kB** 🏆 | 86.6 kB | 180.1 kB | 264.9 kB | 49.9 kB | -| **Conversions** (wei, gwei, ether) | **1.2 kB** 🏆 | 10.4 kB | 2.7 kB | 454.5 kB | 3.7 kB | +| What you import | essential-eth@0.13.0 | ethers@6.16.0 | viem@2.46.0 | web3@4.16.0 | ox@0.12.1 | +| ---------------------------------------- | :------------------: | :-----------: | :---------: | :---------: | :------------: | +| **Full library** | **44.0 kB** 🏆 | 394.0 kB | 385.3 kB | 495.8 kB | 612.8 kB | +| **Provider** (getBalance, getBlock, etc) | 30.0 kB | 260.0 kB | 306.5 kB | 454.5 kB | **10.9 kB** 🏆 | +| **Contract** (read-only calls) | **24.9 kB** 🏆 | 86.6 kB | 180.1 kB | 264.9 kB | 49.9 kB | +| **Conversions** (wei, gwei, ether) | **1.2 kB** 🏆 | 10.4 kB | 2.7 kB | 454.5 kB | 3.7 kB | essential-eth is **8x smaller** than the nearest alternative for full-library usage. @@ -118,6 +118,8 @@ Essential-eth is built for developers where size and speed matter. Check out ded - [`isHexString`](#ishexstring) - [`jsonRpcProvider`](#jsonrpcprovider) - [`keccak256`](#keccak256) + - [`multicall`](#multicall) + - [`multicallSameContract`](#multicallsamecontract) - [`namehash`](#namehash) - [`pack`](#pack) - [`parseUnits`](#parseunits) @@ -149,7 +151,7 @@ Essential-eth is built for developers where size and speed matter. Check out ded - [More Info](#more-info) - [Identical vs Similar vs Dissimilar {#isd}](#identical-vs-similar-vs-dissimilar-isd) - [Miscellaneous](#miscellaneous) -- [Contributing](#contributing) +- [Contributing and GitPOAP](#contributing-and-gitpoap) @@ -174,7 +176,7 @@ Browsers: ```html - + ``` @@ -1006,6 +1008,78 @@ keccak256('0x123');
+#### [`multicall`](https://eeth.dev/docs/api/modules#multicall) + ![](https://deno.bundlejs.com/badge?q=essential-eth&treeshake=[{+multicall+}]) + ```typescript + multicall(provider: MulticallProvider, calls: undefined): Promise + ``` + +
+ View Example + + ```js + import { multicall } from 'essential-eth'; + ``` + + ```typescript +import { JsonRpcProvider, multicall } from 'essential-eth'; + +const provider = new JsonRpcProvider('https://free-eth-node.com/api/eth'); +const results = await multicall(provider, [ + { + target: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + abi: daiAbi, + functionName: 'balanceOf', + args: ['0x...'], + }, + { + target: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + abi: daiAbi, + functionName: 'totalSupply', + }, +]); +// results[0] = { success: true, data: 1000000000000000000n } +// results[1] = { success: true, data: 5000000000000000000000000n } +``` + +
+ +
+ +#### [`multicallSameContract`](https://eeth.dev/docs/api/modules#multicallsamecontract) + ![](https://deno.bundlejs.com/badge?q=essential-eth&treeshake=[{+multicallSameContract+}]) + ```typescript + multicallSameContract(provider: MulticallProvider, contractAddress: string, abi: JSONABI, calls: undefined): Promise + ``` + +
+ View Example + + ```js + import { multicallSameContract } from 'essential-eth'; + ``` + + ```typescript +import { JsonRpcProvider, multicallSameContract } from 'essential-eth'; + +const provider = new JsonRpcProvider('https://free-eth-node.com/api/eth'); +const results = await multicallSameContract( + provider, + '0x6B175474E89094C44Da98b954EedeAC495271d0F', + daiAbi, + [ + { functionName: 'name' }, + { functionName: 'symbol' }, + { functionName: 'decimals' }, + { functionName: 'balanceOf', args: ['0x...'] }, + ], +); +``` + +
+ +
+ #### [`namehash`](https://eeth.dev/docs/api/modules#namehash) ![](https://deno.bundlejs.com/badge?q=essential-eth&treeshake=[{+namehash+}]) ```typescript @@ -1943,6 +2017,8 @@ Note: In `web3.js`, almost every method or function can be passed a callback. `e - [📓 View changelog (by looking at releases diff)](https://github.com/dawsbot/essential-eth/releases) - [📋 View our project board](https://github.com/dawsbot/essential-eth/projects/1) -## Contributing +## Contributing and GitPOAP We welcome and appreciate all contributions to Essential Eth! If you're interested in helping us improve this library, please read our [Contributing Guidelines](https://github.com/dawsbot/essential-eth/blob/master/CONTRIBUTING.md) to understand the types of contributions we're looking for and the process of making them. + +In partnership with GitPOAP, Essential ETH wants to recognize **all** contributors for their contributions toward the growth of this library. More information about GitPOAP can be found on the [Contributing Guidelines](https://github.com/dawsbot/essential-eth/blob/master/CONTRIBUTING.md#GitPOAP). diff --git a/src/index.ts b/src/index.ts index 1ed86882..ab3a8887 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,3 +78,9 @@ export { toChecksumAddress } from './utils/to-checksum-address'; export { toUtf8Bytes } from './utils/to-utf8-bytes'; export { toUtf8String } from './utils/to-utf8-string'; export { weiToEther } from './utils/wei-to-ether'; +export { + multicall, + multicallSameContract, + MulticallCall, + MulticallResult, +} from './utils/multicall'; diff --git a/src/utils/multicall.ts b/src/utils/multicall.ts new file mode 100644 index 00000000..0f5a3d80 --- /dev/null +++ b/src/utils/multicall.ts @@ -0,0 +1,306 @@ +import { keccak_256 } from '@noble/hashes/sha3.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { + decodeRPCResponse, + encodeData, +} from '../classes/utils/encode-decode-transaction'; +import type { JSONABI, JSONABIArgument } from '../types/Contract.types'; + +/** + * Multicall3 is deployed at the same address on all major EVM chains. + * @see https://www.multicall3.com/ + */ +const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'; + +/** + * Function selector for aggregate3((address,bool,bytes)[]) + * Computed as the first 4 bytes of keccak256("aggregate3((address,bool,bytes)[])") + */ +const AGGREGATE3_SELECTOR = (() => { + const hash = bytesToHex( + keccak_256( + new TextEncoder().encode('aggregate3((address,bool,bytes)[])'), + ), + ); + return hash.slice(0, 8); +})(); + +/** + * A single call to include in a multicall batch. + */ +export interface MulticallCall { + /** The target contract address */ + target: string; + /** The JSON ABI of the target contract (or at least the entry for the function being called) */ + abi: JSONABI; + /** The name of the function to call */ + functionName: string; + /** The arguments to pass to the function */ + args?: any[]; +} + +/** + * The result of a single call within a multicall batch. + */ +export interface MulticallResult { + /** Whether the call succeeded */ + success: boolean; + /** The decoded return data, or null if the call failed */ + data: any; +} + +/** + * Minimal provider interface for multicall — compatible with JsonRpcProvider, FallthroughProvider, etc. + */ +interface MulticallProvider { + call( + transaction: { to?: string; data?: any }, + blockTag?: string | number, + ): Promise; +} + +/** + * Encode a single aggregate3 call tuple (address, bool, bytes). + * @internal + */ +function encodeTuple( + target: string, + allowFailure: boolean, + callData: string, +): string { + // address: left-padded to 32 bytes + const addressHex = target.replace(/^0x/, '').toLowerCase().padStart(64, '0'); + // bool: 32 bytes + const boolHex = allowFailure ? '0'.repeat(63) + '1' : '0'.repeat(64); + // bytes is dynamic — offset pointer within the tuple + // 3 head slots (address, bool, bytes-offset) × 32 bytes = 96 = 0x60 + const bytesOffset = (3 * 32).toString(16).padStart(64, '0'); + // bytes data + const rawBytes = callData.replace(/^0x/, ''); + const bytesLength = (rawBytes.length / 2).toString(16).padStart(64, '0'); + const paddedBytes = rawBytes.padEnd( + Math.ceil(rawBytes.length / 64) * 64, + '0', + ); + + return addressHex + boolHex + bytesOffset + bytesLength + paddedBytes; +} + +/** + * Encode the full aggregate3 call data (function selector + ABI-encoded parameters). + * @internal + */ +function encodeAggregate3( + calls: { target: string; allowFailure: boolean; callData: string }[], +): string { + // Function selector + let result = AGGREGATE3_SELECTOR; + + // The input is a dynamic array — first word is the offset to array data (0x20 = 32) + result += (32).toString(16).padStart(64, '0'); + + // Array length + result += calls.length.toString(16).padStart(64, '0'); + + // Each tuple is dynamic (contains `bytes`), so we emit offset pointers first, then data + const encodedTuples: string[] = calls.map((call) => + encodeTuple(call.target, call.allowFailure, call.callData), + ); + + // Offsets are relative to the start of the element-offset area (right after the length word) + let currentOffset = calls.length * 32; // skip past all offset slots + const offsets: string[] = []; + for (let i = 0; i < encodedTuples.length; i++) { + offsets.push(currentOffset.toString(16).padStart(64, '0')); + currentOffset += encodedTuples[i].length / 2; // hex chars → bytes + } + + result += offsets.join(''); + result += encodedTuples.join(''); + + return '0x' + result; +} + +/** + * Decode the aggregate3 response — an ABI-encoded `(bool, bytes)[]`. + * @internal + */ +function decodeAggregate3Response( + response: string, +): { success: boolean; returnData: string }[] { + const data = response.replace(/^0x/, ''); + + // First 32 bytes: offset to array data (in bytes), convert to hex-char offset + const arrayOffset = parseInt(data.slice(0, 64), 16) * 2; + + // At arrayOffset: array length + const arrayLength = parseInt( + data.slice(arrayOffset, arrayOffset + 64), + 16, + ); + + const results: { success: boolean; returnData: string }[] = []; + + for (let i = 0; i < arrayLength; i++) { + // Offset slot for element i (relative to element-offset area start) + const offsetSlot = arrayOffset + 64 + i * 64; + const tupleOffset = + arrayOffset + + 64 + + parseInt(data.slice(offsetSlot, offsetSlot + 64), 16) * 2; + + // Tuple: (bool success, bytes returnData) + // success (32 bytes) + const success = + data.slice(tupleOffset, tupleOffset + 64) === '0'.repeat(63) + '1'; + + // offset to bytes within this tuple (32 bytes) + const bytesOffset = + parseInt(data.slice(tupleOffset + 64, tupleOffset + 128), 16) * 2; + const bytesStart = tupleOffset + bytesOffset; + + // bytes: length (32 bytes) + data + const bytesLength = parseInt(data.slice(bytesStart, bytesStart + 64), 16); + const returnData = + bytesLength > 0 + ? '0x' + data.slice(bytesStart + 64, bytesStart + 64 + bytesLength * 2) + : '0x'; + + results.push({ success, returnData }); + } + + return results; +} + +/** + * Batch multiple contract read calls into a single RPC request via Multicall3's `aggregate3`. + * + * Reduces the number of RPC round-trips from N to 1 when reading from multiple contracts + * (or multiple functions on the same contract). + * + * @param provider An essential-eth provider (JsonRpcProvider, FallthroughProvider, etc.) + * @param calls Array of calls to batch — each specifies a target address, ABI, function name, and optional args + * @returns Array of results in the same order as the input calls + * + * @example + * ```typescript + * import { JsonRpcProvider, multicall } from 'essential-eth'; + * + * const provider = new JsonRpcProvider('https://free-eth-node.com/api/eth'); + * const results = await multicall(provider, [ + * { + * target: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + * abi: daiAbi, + * functionName: 'balanceOf', + * args: ['0x...'], + * }, + * { + * target: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + * abi: daiAbi, + * functionName: 'totalSupply', + * }, + * ]); + * // results[0] = { success: true, data: 1000000000000000000n } + * // results[1] = { success: true, data: 5000000000000000000000000n } + * ``` + */ +export async function multicall( + provider: MulticallProvider, + calls: MulticallCall[], +): Promise { + // Find the ABI entry for each call and encode the call data + const encodedCalls = calls.map((call) => { + const abiEntry = call.abi.find( + (entry) => entry.type === 'function' && entry.name === call.functionName, + ); + if (!abiEntry) { + throw new Error(`Function "${call.functionName}" not found in ABI`); + } + const callData = encodeData( + abiEntry as JSONABIArgument, + call.args || [], + ); + return { + target: call.target, + allowFailure: true, + callData, + }; + }); + + // Encode the full aggregate3 call + const data = encodeAggregate3(encodedCalls); + + // Send the batched call via eth_call + const response = await provider.call( + { + to: MULTICALL3_ADDRESS.toLowerCase(), + data, + }, + 'latest', + ); + + // Decode the aggregate response, then decode each individual result + const rawResults = decodeAggregate3Response(response); + + return rawResults.map((result, i) => { + if (!result.success) { + return { success: false, data: null }; + } + + const abiEntry = calls[i].abi.find( + (entry) => + entry.type === 'function' && entry.name === calls[i].functionName, + ) as JSONABIArgument; + + try { + const decoded = decodeRPCResponse(abiEntry, result.returnData); + return { success: true, data: decoded }; + } catch { + return { success: false, data: null }; + } + }); +} + +/** + * Convenience function for calling multiple functions on the same contract via Multicall3. + * + * @param provider An essential-eth provider + * @param contractAddress The address of the target contract + * @param abi The JSON ABI of the target contract + * @param calls Array of function calls — each specifies a function name and optional args + * @returns Array of results in the same order as the input calls + * + * @example + * ```typescript + * import { JsonRpcProvider, multicallSameContract } from 'essential-eth'; + * + * const provider = new JsonRpcProvider('https://free-eth-node.com/api/eth'); + * const results = await multicallSameContract( + * provider, + * '0x6B175474E89094C44Da98b954EedeAC495271d0F', + * daiAbi, + * [ + * { functionName: 'name' }, + * { functionName: 'symbol' }, + * { functionName: 'decimals' }, + * { functionName: 'balanceOf', args: ['0x...'] }, + * ], + * ); + * ``` + */ +export async function multicallSameContract( + provider: MulticallProvider, + contractAddress: string, + abi: JSONABI, + calls: { functionName: string; args?: any[] }[], +): Promise { + return multicall( + provider, + calls.map((call) => ({ + target: contractAddress, + abi, + functionName: call.functionName, + args: call.args, + })), + ); +} diff --git a/src/utils/tests/multicall.test.ts b/src/utils/tests/multicall.test.ts new file mode 100644 index 00000000..6d992ba5 --- /dev/null +++ b/src/utils/tests/multicall.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { JSONABI } from '../../types/Contract.types'; +import { multicall, multicallSameContract } from '../multicall'; + +// --- Test ABIs --- + +const boolAbi: JSONABI = [ + { + inputs: [ + { name: 'index', type: 'uint256' }, + ], + name: 'isClaimed', + outputs: [ + { name: '', type: 'bool' }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +const uint256Abi: JSONABI = [ + { + inputs: [], + name: 'totalSupply', + outputs: [ + { name: '', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +const balanceOfAbi: JSONABI = [ + { + inputs: [ + { name: 'account', type: 'address' }, + ], + name: 'balanceOf', + outputs: [ + { name: '', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +const combinedAbi: JSONABI = [ + ...uint256Abi, + ...balanceOfAbi, +]; + +// --- Helper: build a mock aggregate3 response --- + +/** + * Builds an ABI-encoded aggregate3 response: `(bool, bytes)[]` + * + * Layout (all values in 32-byte words): + * - offset to array data (0x20) + * - array length + * - per-element offset pointers (relative to element-offset area) + * - per-element: (bool success, offset-to-bytes, bytes-length, bytes-data-padded) + */ +function buildAggregate3Response( + results: { success: boolean; returnData: string }[], +): string { + const word = (n: number) => n.toString(16).padStart(64, '0'); + const boolWord = (b: boolean) => (b ? '0'.repeat(63) + '1' : '0'.repeat(64)); + + let hex = ''; + + // Offset to array + hex += word(32); + + // Array length + hex += word(results.length); + + // Each tuple is dynamic, so we emit offsets then data + // Encode each tuple first so we know their sizes + const encodedTuples: string[] = results.map((r) => { + const rawBytes = r.returnData.replace(/^0x/, ''); + const paddedBytes = rawBytes.length > 0 + ? rawBytes.padEnd(Math.ceil(rawBytes.length / 64) * 64, '0') + : ''; + + return ( + boolWord(r.success) + // success + word(2 * 32) + // offset to bytes within tuple (0x40) + word(rawBytes.length / 2) + // bytes length + paddedBytes // bytes data + ); + }); + + // Offsets (relative to start of element-offset area) + let currentOffset = results.length * 32; // skip past all offset slots + for (let i = 0; i < encodedTuples.length; i++) { + hex += word(currentOffset); + currentOffset += encodedTuples[i].length / 2; + } + + // Tuple data + hex += encodedTuples.join(''); + + return '0x' + hex; +} + +// --- Tests --- + +describe('multicall', () => { + it('correctly encodes calls and decodes successful results', async () => { + // Mock response: isClaimed → true, totalSupply → 42 + const mockResponse = buildAggregate3Response([ + { + success: true, + // ABI-encoded bool true + returnData: '0x' + '0'.repeat(63) + '1', + }, + { + success: true, + // ABI-encoded uint256 42 (0x2a) + returnData: '0x' + '0'.repeat(62) + '2a', + }, + ]); + + const mockProvider = { + call: vi.fn().mockResolvedValue(mockResponse), + }; + + const results = await multicall(mockProvider, [ + { + target: '0x090D4613473dEE047c3f2706764f49E0821D256e', + abi: boolAbi, + functionName: 'isClaimed', + args: [0], + }, + { + target: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', + abi: uint256Abi, + functionName: 'totalSupply', + }, + ]); + + // Verify provider.call was called once (batched) + expect(mockProvider.call).toHaveBeenCalledTimes(1); + + // Verify the call was to the Multicall3 address + const callArgs = mockProvider.call.mock.calls[0]; + expect(callArgs[0].to).toBe( + '0xca11bde05977b3631167028862be2a173976ca11', + ); + expect(callArgs[1]).toBe('latest'); + + // Verify results + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ success: true, data: true }); + expect(results[1]).toEqual({ success: true, data: BigInt(42) }); + }); + + it('handles failed calls returning { success: false, data: null }', async () => { + const mockResponse = buildAggregate3Response([ + { + success: true, + // ABI-encoded bool true + returnData: '0x' + '0'.repeat(63) + '1', + }, + { + success: false, + // Empty return data for failed call + returnData: '0x', + }, + ]); + + const mockProvider = { + call: vi.fn().mockResolvedValue(mockResponse), + }; + + const results = await multicall(mockProvider, [ + { + target: '0x090D4613473dEE047c3f2706764f49E0821D256e', + abi: boolAbi, + functionName: 'isClaimed', + args: [0], + }, + { + target: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', + abi: uint256Abi, + functionName: 'totalSupply', + }, + ]); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ success: true, data: true }); + expect(results[1]).toEqual({ success: false, data: null }); + }); + + it('throws when function name is not found in ABI', async () => { + const mockProvider = { + call: vi.fn(), + }; + + await expect( + multicall(mockProvider, [ + { + target: '0x090D4613473dEE047c3f2706764f49E0821D256e', + abi: boolAbi, + functionName: 'nonExistentFunction', + }, + ]), + ).rejects.toThrow('Function "nonExistentFunction" not found in ABI'); + }); + + it('encodes call data in the aggregate3 payload', async () => { + const mockResponse = buildAggregate3Response([ + { + success: true, + returnData: '0x' + '0'.repeat(63) + '1', + }, + ]); + + const mockProvider = { + call: vi.fn().mockResolvedValue(mockResponse), + }; + + await multicall(mockProvider, [ + { + target: '0x090D4613473dEE047c3f2706764f49E0821D256e', + abi: boolAbi, + functionName: 'isClaimed', + args: [0], + }, + ]); + + // The call data sent to the provider should start with the aggregate3 selector + const callData = mockProvider.call.mock.calls[0][0].data as string; + expect(callData).toMatch(/^0x[0-9a-f]+$/); // valid hex + // aggregate3 selector is the first 10 chars (0x + 8 hex) + expect(callData.length).toBeGreaterThan(10); + }); +}); + +describe('multicallSameContract', () => { + it('batches multiple function calls to the same contract', async () => { + const contractAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const account = '0x71660c4005BA85c37ccec55d0C4493E66Fe775d3'; + + const mockResponse = buildAggregate3Response([ + { + success: true, + // ABI-encoded uint256 1000000 (0xF4240) + returnData: '0x' + '0'.repeat(59) + 'f4240', + }, + { + success: true, + // ABI-encoded uint256 500 (0x1F4) + returnData: '0x' + '0'.repeat(61) + '1f4', + }, + ]); + + const mockProvider = { + call: vi.fn().mockResolvedValue(mockResponse), + }; + + const results = await multicallSameContract( + mockProvider, + contractAddress, + combinedAbi, + [ + { functionName: 'totalSupply' }, + { functionName: 'balanceOf', args: [account] }, + ], + ); + + // Single RPC call + expect(mockProvider.call).toHaveBeenCalledTimes(1); + + // Verify results + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ success: true, data: BigInt(1000000) }); + expect(results[1]).toEqual({ success: true, data: BigInt(500) }); + }); + + it('handles mixed success and failure in same-contract calls', async () => { + const contractAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const mockResponse = buildAggregate3Response([ + { + success: true, + returnData: '0x' + '0'.repeat(59) + 'f4240', + }, + { + success: false, + returnData: '0x', + }, + ]); + + const mockProvider = { + call: vi.fn().mockResolvedValue(mockResponse), + }; + + const results = await multicallSameContract( + mockProvider, + contractAddress, + combinedAbi, + [ + { functionName: 'totalSupply' }, + { functionName: 'balanceOf', args: ['0x0000000000000000000000000000000000000000'] }, + ], + ); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ success: true, data: BigInt(1000000) }); + expect(results[1]).toEqual({ success: false, data: null }); + }); +});