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)
+ 
+ ```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)
+ 
+ ```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)

```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 });
+ });
+});