diff --git a/src/core/Multicall.ts b/src/core/Multicall.ts new file mode 100644 index 00000000..90e794c5 --- /dev/null +++ b/src/core/Multicall.ts @@ -0,0 +1,206 @@ +import { WhiteChainError } from "../types.js"; +import type { + Multicall3Call, + Multicall3CallResult, + Multicall3Options, +} from "../types/multicall.js"; + +/** + * Official canonical Multicall3 deployment address on Whitechain and EVM networks. + */ +export const DEFAULT_MULTICALL3_ADDRESS = + "0xcA11bde05977b3631167028862bE2a173976CA11"; + +/** + * Function signature selector for Multicall3 `aggregate3((address,bool,bytes)[])`. + * keccak256("aggregate3((address,bool,bytes)[])")[0..4] => 0x82ad56cb + */ +export const AGGREGATE3_SELECTOR = "0x82ad56cb"; + +export type RpcFetchFn = (method: string, params: any[]) => Promise; + +/** + * Encodes an array of Multicall3Call objects into ABI-compliant aggregate3 calldata. + */ +export function encodeAggregate3( + calls: Multicall3Call[], + globalAllowFailure = true +): `0x${string}` { + if (calls.length === 0) { + throw new WhiteChainError("Multicall requires at least one call in the batch."); + } + + // ABI Encoding for: aggregate3(Call3[] calls) where Call3 is (address target, bool allowFailure, bytes callData) + // Dynamic array offset: 0x20 (32 bytes) + const arrayLenHex = calls.length.toString(16).padStart(64, "0"); + + let headBytes = ""; + let tailBytes = ""; + + // Head contains relative offsets for each struct element in the array + const headSize = calls.length * 32; + + let currentTailOffset = headSize; + + for (let i = 0; i < calls.length; i++) { + const call = calls[i]; + const allowFailure = call.allowFailure ?? globalAllowFailure; + + // Sanitize target address to 32-byte padded hex + const targetAddrPadded = call.target.toLowerCase().replace(/^0x/, "").padStart(64, "0"); + const allowFailurePadded = (allowFailure ? 1 : 0).toString(16).padStart(64, "0"); + + const rawCallData = call.callData.replace(/^0x/, ""); + const callDataLenHex = (rawCallData.length / 2).toString(16).padStart(64, "0"); + + // Pad callData to 32-byte boundary + const paddedCallData = rawCallData.padEnd(Math.ceil(rawCallData.length / 64) * 64 || 64, "0"); + + // Struct offset relative to array start + const structOffsetHex = currentTailOffset.toString(16).padStart(64, "0"); + headBytes += structOffsetHex; + + // Struct layout: target (32b), allowFailure (32b), callData offset (32b = 0x60), callData length (32b), callData content + const structHead = targetAddrPadded + allowFailurePadded + (0x60).toString(16).padStart(64, "0"); + const structTail = callDataLenHex + paddedCallData; + + const structBytes = structHead + structTail; + tailBytes += structBytes; + + currentTailOffset += structBytes.length / 2; + } + + // 0x20 = offset to array data + const arrayOffsetHex = (0x20).toString(16).padStart(64, "0"); + return `${AGGREGATE3_SELECTOR}${arrayOffsetHex}${arrayLenHex}${headBytes}${tailBytes}` as `0x${string}`; +} + +/** + * Decodes the return bytes from Multicall3 aggregate3 into typed Result structures. + */ +export function decodeAggregate3Results( + returnDataHex: `0x${string}`, + calls: TCalls +): Multicall3CallResult[] { + const cleanHex = returnDataHex.replace(/^0x/, ""); + + if (cleanHex.length < 64) { + throw new WhiteChainError("Invalid return data length from Multicall3 aggregate3 call."); + } + + const results: Multicall3CallResult[] = []; + + // ABI return tuple: Result[] where Result is (bool success, bytes returnData) + // Skip array offset (32b) and array length (32b) + const count = parseInt(cleanHex.slice(64, 128), 16); + + if (count !== calls.length) { + throw new WhiteChainError( + `Multicall result count mismatch: expected ${calls.length}, got ${count}` + ); + } + + const arrayDataHex = cleanHex.slice(128); + + for (let i = 0; i < count; i++) { + const call = calls[i]; + + // Read struct head pointer (offset from array data start) + const structOffset = parseInt(arrayDataHex.slice(i * 64, (i + 1) * 64), 16) * 2; + const structHex = arrayDataHex.slice(structOffset); + + const success = parseInt(structHex.slice(0, 64), 16) === 1; + + // Bytes offset is structHex.slice(64, 128) -> usually 0x40 + const bytesLen = parseInt(structHex.slice(128, 192), 16); + const rawBytes = structHex.slice(192, 192 + bytesLen * 2); + const returnData = `0x${rawBytes}` as `0x${string}`; + + let decodedValue: any = undefined; + let errorMsg: string | undefined = undefined; + + if (success) { + if (call.decoder) { + try { + decodedValue = call.decoder(returnData); + } catch (err: any) { + errorMsg = `Decoder error: ${err.message}`; + } + } else { + decodedValue = returnData; + } + } else { + errorMsg = "Call reverted or failed execution on-chain."; + } + + results.push({ + success, + returnData, + value: decodedValue, + error: errorMsg, + }); + } + + return results; +} + +export class Multicall { + public readonly multicallAddress: string; + public readonly defaultAllowFailure: boolean; + private readonly rpcFetchFn: RpcFetchFn; + + constructor(rpcFetchFn: RpcFetchFn, options: Multicall3Options = {}) { + if (!rpcFetchFn) { + throw new WhiteChainError("RPC fetch function is required for Multicall initialization."); + } + this.rpcFetchFn = rpcFetchFn; + this.multicallAddress = options.multicallAddress || DEFAULT_MULTICALL3_ADDRESS; + this.defaultAllowFailure = options.allowFailure ?? true; + } + + /** + * Batches multiple view calls into a single RPC eth_call request to Multicall3. + * Batching 50 queries results in exactly 1 HTTP RPC request. + */ + async execute( + calls: TCalls, + options: Multicall3Options = {} + ): Promise<{ [K in keyof TCalls]: Multicall3CallResult }> { + const targetAddress = options.multicallAddress || this.multicallAddress; + const blockTag = + options.blockNumber !== undefined + ? typeof options.blockNumber === "number" || typeof options.blockNumber === "bigint" + ? `0x${options.blockNumber.toString(16)}` + : options.blockNumber + : "latest"; + + const allowFailure = options.allowFailure ?? this.defaultAllowFailure; + const calldata = encodeAggregate3(calls as any, allowFailure); + + // Exactly 1 eth_call HTTP RPC request sent + const returnDataHex = await this.rpcFetchFn("eth_call", [ + { + to: targetAddress, + data: calldata, + }, + blockTag, + ]); + + if (!returnDataHex || typeof returnDataHex !== "string") { + throw new WhiteChainError("Invalid or empty response returned from eth_call Multicall3."); + } + + const results = decodeAggregate3Results(returnDataHex as `0x${string}`, calls); + return results as any; + } +} + +/** + * Factory helper function to instantiate a Multicall instance. + */ +export function createMulticall( + rpcFetchFn: RpcFetchFn, + options?: Multicall3Options +): Multicall { + return new Multicall(rpcFetchFn, options); +} diff --git a/src/core/index.ts b/src/core/index.ts index 76ac6038..3b2769c7 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,14 @@ export { Contract, type ContractClient } from './Contract.js' +export { DynamicContract } from './DynamicContract.js' +export { TransactionHelper } from './TransactionHelper.js' +export { + Multicall, + createMulticall, + DEFAULT_MULTICALL3_ADDRESS, + encodeAggregate3, + decodeAggregate3Results, + type RpcFetchFn, +} from './Multicall.js' export { Contract } from './Contract.js' export { Contract, type ContractClient } from './Contract.js' export { DynamicContract } from './DynamicContract.js' diff --git a/src/index.ts b/src/index.ts index b89dfc4c..69261ceb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,17 @@ export type { RpcProviderConfig } from './types/config.js' export { Contract, type ContractClient } from './core/Contract.js' export { HDWallet, createHDWallet, type HDWalletOptions } from './wallet/HDWallet.js' export { + Multicall, + createMulticall, + DEFAULT_MULTICALL3_ADDRESS, + encodeAggregate3, + decodeAggregate3Results, +} from './core/Multicall.js' +export type { + Multicall3Call, + Multicall3CallResult, + Multicall3Options, +} from './types/multicall.js' Contract, type ContractClient, ContractWrapper, diff --git a/src/network/provider.ts b/src/network/provider.ts index 2190e500..3a19f223 100644 --- a/src/network/provider.ts +++ b/src/network/provider.ts @@ -95,6 +95,15 @@ export interface ProviderOptions { fetchFn?: typeof fetch } +export interface ProviderOptions { + /** Maximum number of retry attempts on HTTP 429 rate limit responses (default: 3). */ + maxRetries?: number + /** Base delay in milliseconds for exponential backoff calculations (default: 100). */ + baseDelayMs?: number + /** Custom fetch implementation for network requests or testing. */ + fetchFn?: typeof fetch +} + export class Provider { public readonly network: NetworkProfile public readonly chainId: number diff --git a/src/types/multicall.ts b/src/types/multicall.ts new file mode 100644 index 00000000..76782fad --- /dev/null +++ b/src/types/multicall.ts @@ -0,0 +1,64 @@ +export interface Multicall3Call { + /** + * The target contract address to execute the view call against. + */ + target: string; + + /** + * The encoded ABI calldata (0x...) for the target view function. + */ + callData: `0x${string}`; + + /** + * If true, failure of this individual call will not revert the entire multicall. + * @default true + */ + allowFailure?: boolean; + + /** + * Optional custom decoding function to transform raw return bytes into typed values. + */ + decoder?: (returnData: `0x${string}`) => T; +} + +export interface Multicall3CallResult { + /** + * True if the call succeeded without reverting. + */ + success: boolean; + + /** + * The raw hex bytes returned by the target function. + */ + returnData: `0x${string}`; + + /** + * The decoded value returned by the decoder function, if provided and successful. + */ + value?: T; + + /** + * Error message if the call failed or reverted. + */ + error?: string; +} + +export interface Multicall3Options { + /** + * Configurable Multicall3 contract address. + * Defaults to official Whitechain Multicall3 deployment (0xcA11bde05977b3631167028862bE2a173976CA11). + */ + multicallAddress?: string; + + /** + * Block number or tag to execute the call against. + * @default "latest" + */ + blockNumber?: number | bigint | string; + + /** + * Global default for allowFailure across all calls in the batch. + * @default true + */ + allowFailure?: boolean; +} diff --git a/tests/core/Multicall.test.ts b/tests/core/Multicall.test.ts new file mode 100644 index 00000000..583c8046 --- /dev/null +++ b/tests/core/Multicall.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi } from "vitest"; +import { + Multicall, + createMulticall, + DEFAULT_MULTICALL3_ADDRESS, + encodeAggregate3, + decodeAggregate3Results, +} from "../../src/core/Multicall.js"; +import { WhiteChainError } from "../../src/types.js"; +import type { Multicall3Call } from "../../src/types/multicall.js"; + +describe("Multicall", () => { + it("uses DEFAULT_MULTICALL3_ADDRESS by default and allows address override", () => { + const rpcFetchFn = vi.fn(); + const defaultClient = new Multicall(rpcFetchFn); + expect(defaultClient.multicallAddress).toBe(DEFAULT_MULTICALL3_ADDRESS); + + const customAddress = "0x1111111111111111111111111111111111111111"; + const customClient = createMulticall(rpcFetchFn, { multicallAddress: customAddress }); + expect(customClient.multicallAddress).toBe(customAddress); + }); + + it("batches 50 view queries into exactly 1 HTTP RPC request", async () => { + let rpcCallCount = 0; + + const mockRpcFetchFn = vi.fn().mockImplementation(async (method: string, params: any[]) => { + if (method === "eth_call") { + rpcCallCount++; + const to = params[0].to; + expect(to).toBe(DEFAULT_MULTICALL3_ADDRESS); + + // Build a mock ABI response for 50 calls + // Result array header: offset (0x20), count (50) + let hex = "0000000000000000000000000000000000000000000000000000000000000020"; + hex += (50).toString(16).padStart(64, "0"); + + // Array struct pointers (50 elements * 32 bytes) + const elementSize = 32 + 32 + 32 + 32 + 32; // 160 bytes = 0xa0 per struct + for (let i = 0; i < 50; i++) { + const structOffset = 50 * 32 + i * 160; + hex += structOffset.toString(16).padStart(64, "0"); + } + + // 50 Result structs (success = 1, returnData = 32 bytes value) + for (let i = 0; i < 50; i++) { + hex += "0000000000000000000000000000000000000000000000000000000000000001"; // success = true + hex += "0000000000000000000000000000000000000000000000000000000000000040"; // bytes offset = 0x40 + hex += "0000000000000000000000000000000000000000000000000000000000000020"; // bytes length = 32 + hex += (i + 1).toString(16).padStart(64, "0"); // uint256 value (i + 1) + hex += "0000000000000000000000000000000000000000000000000000000000000000"; // padding + } + + return `0x${hex}`; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + + const multicall = new Multicall(mockRpcFetchFn); + + // Create 50 calls + const calls: Multicall3Call[] = Array.from({ length: 50 }, (_, idx) => ({ + target: `0x${(idx + 1).toString(16).padStart(40, "0")}`, + callData: "0x70a082310000000000000000000000001111111111111111111111111111111111111111", // balanceOf + decoder: (hex) => BigInt(hex), + })); + + const results = await multicall.execute(calls); + + // ACCEPTANCE CRITERIA: Batching 50 queries results in EXACTLY 1 HTTP RPC request + expect(rpcCallCount).toBe(1); + expect(mockRpcFetchFn).toHaveBeenCalledTimes(1); + + expect(results).toHaveLength(50); + expect(results[0].success).toBe(true); + expect(results[0].value).toBe(1n); + expect(results[49].value).toBe(50n); + }); + + it("handles partial failures gracefully when allowFailure is true", async () => { + const mockRpcFetchFn = vi.fn().mockImplementation(async () => { + // Return data for 2 calls: call 0 succeeded, call 1 failed + let hex = "0000000000000000000000000000000000000000000000000000000000000020"; + hex += (2).toString(16).padStart(64, "0"); // 2 items + + const offset0 = (2 * 32).toString(16).padStart(64, "0"); + const offset1 = (2 * 32 + 160).toString(16).padStart(64, "0"); + hex += offset0 + offset1; + + // Call 0: Success + hex += "0000000000000000000000000000000000000000000000000000000000000001"; // success = true + hex += "0000000000000000000000000000000000000000000000000000000000000040"; + hex += "0000000000000000000000000000000000000000000000000000000000000020"; + hex += "0000000000000000000000000000000000000000000000000000000000000064"; // 100 + hex += "0000000000000000000000000000000000000000000000000000000000000000"; + + // Call 1: Reverted (success = 0) + hex += "0000000000000000000000000000000000000000000000000000000000000000"; // success = false + hex += "0000000000000000000000000000000000000000000000000000000000000040"; + hex += "0000000000000000000000000000000000000000000000000000000000000000"; // 0 length + hex += "0000000000000000000000000000000000000000000000000000000000000000"; + hex += "0000000000000000000000000000000000000000000000000000000000000000"; + + return `0x${hex}`; + }); + + const multicall = new Multicall(mockRpcFetchFn); + + const calls: Multicall3Call[] = [ + { + target: "0x1111111111111111111111111111111111111111", + callData: "0x70a08231", + allowFailure: true, + decoder: (hex) => BigInt(hex), + }, + { + target: "0x2222222222222222222222222222222222222222", + callData: "0x70a08231", + allowFailure: true, + decoder: (hex) => BigInt(hex), + }, + ]; + + const results = await multicall.execute(calls); + + expect(results[0].success).toBe(true); + expect(results[0].value).toBe(100n); + + // Call 1 failed gracefully without throwing, returning success = false + expect(results[1].success).toBe(false); + expect(results[1].error).toBe("Call reverted or failed execution on-chain."); + expect(results[1].value).toBeUndefined(); + }); + + it("throws WhiteChainError if input calls array is empty or eth_call fails", async () => { + const mockFetchFn = vi.fn().mockResolvedValue(null); + const multicall = new Multicall(mockFetchFn); + + await expect(multicall.execute([])).rejects.toThrow(WhiteChainError); + await expect( + multicall.execute([{ target: "0x1", callData: "0x1234" }]) + ).rejects.toThrow(WhiteChainError); + }); +});