diff --git a/src/core/Multicall.ts b/src/core/Multicall.ts index 90e794c5..13329a0d 100644 --- a/src/core/Multicall.ts +++ b/src/core/Multicall.ts @@ -1,4 +1,4 @@ -import { WhiteChainError } from "../types.js"; +import { ValidationError, WhiteChainError } from "../errors/index.js"; import type { Multicall3Call, Multicall3CallResult, @@ -19,6 +19,34 @@ export const AGGREGATE3_SELECTOR = "0x82ad56cb"; export type RpcFetchFn = (method: string, params: any[]) => Promise; +/** + * Returns true when a raw calldata string is 0x-prefixed, byte-aligned hex. + */ +export function isValidHexData(data: string): data is `0x${string}` { + return typeof data === "string" && /^0x(?:[0-9a-fA-F]{2})*$/.test(data); +} + +function assertValidHexData(data: string, label: string): `0x${string}` { + if (typeof data !== "string") { + throw new ValidationError(`${label} must be a hex string.`); + } + + if (!data.startsWith("0x")) { + throw new ValidationError(`${label} must start with 0x.`); + } + + const rawData = data.slice(2); + if (rawData.length % 2 !== 0) { + throw new ValidationError(`${label} must contain an even number of hex characters.`); + } + + if (!/^[0-9a-fA-F]*$/.test(rawData)) { + throw new ValidationError(`${label} contains non-hex characters.`); + } + + return data as `0x${string}`; +} + /** * Encodes an array of Multicall3Call objects into ABI-compliant aggregate3 calldata. */ @@ -50,7 +78,7 @@ export function encodeAggregate3( 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 rawCallData = assertValidHexData(call.callData, `calls[${i}].callData`).slice(2); const callDataLenHex = (rawCallData.length / 2).toString(16).padStart(64, "0"); // Pad callData to 32-byte boundary diff --git a/tests/core/Multicall.test.ts b/tests/core/Multicall.test.ts index 583c8046..1c1934c1 100644 --- a/tests/core/Multicall.test.ts +++ b/tests/core/Multicall.test.ts @@ -5,7 +5,9 @@ import { DEFAULT_MULTICALL3_ADDRESS, encodeAggregate3, decodeAggregate3Results, + isValidHexData, } from "../../src/core/Multicall.js"; +import { ValidationError } from "../../src/errors/index.js"; import { WhiteChainError } from "../../src/types.js"; import type { Multicall3Call } from "../../src/types/multicall.js"; @@ -131,6 +133,27 @@ describe("Multicall", () => { expect(results[1].value).toBeUndefined(); }); + it("validates raw calldata before encoding aggregate3 payloads", () => { + const target = "0x1111111111111111111111111111111111111111"; + + expect(isValidHexData("0x")).toBe(true); + expect(isValidHexData("0x70a08231")).toBe(true); + expect(isValidHexData("0x70A08231")).toBe(true); + expect(isValidHexData("70a08231")).toBe(false); + expect(isValidHexData("0xabc")).toBe(false); + expect(isValidHexData("0xzz")).toBe(false); + + expect(() => encodeAggregate3([{ target, callData: "70a08231" as any }])).toThrow( + ValidationError + ); + expect(() => encodeAggregate3([{ target, callData: "0xabc" as any }])).toThrow( + "calls[0].callData must contain an even number of hex characters." + ); + expect(() => encodeAggregate3([{ target, callData: "0xzz" as any }])).toThrow( + "calls[0].callData contains non-hex characters." + ); + }); + 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);