import type { HardhatUserConfig } from "../../../config.js"; import type { ConfigurationVariableResolver, HardhatConfig, ResolvedConfigurationVariable, } from "../../../types/config.js"; import type { HardhatUserConfigValidationError } from "../../../types/hooks.js"; import type { SolidityTestForkingConfig, SolidityTestProfileConfig, SolidityTestProfileUserConfig, } from "../../../types/test.js"; import path from "node:path"; import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors"; import { isObject } from "@nomicfoundation/hardhat-utils/lang"; import { resolveFromRoot } from "@nomicfoundation/hardhat-utils/path"; import { conditionalUnionType, incompatibleFieldType, sensitiveStringSchema, sensitiveUrlSchema, unionType, validateUserConfigZodType, } from "@nomicfoundation/hardhat-zod-utils"; import { z } from "zod"; import { DEFAULT_TEST_PROFILE, RESERVED_TEST_PROFILE_NAMES, TEST_PROFILE_NAME_PATTERN, } from "./test-profiles.js"; // the keccak256 of "built for ethereum" export const DEFAULT_FUZZ_SEED = "0x7727ea51af0441c20da14dcd68a15dac8c9ebd589c5be8fa8c87c1d3720450bc"; const nonnegativeSafeIntOrBigInt = unionType( [z.number().int().nonnegative().safe(), z.bigint().nonnegative()], "Expected a nonnegative safe int or a nonnegative bigint", ); const nonnegativeSafeIntOrSafeBigInt = unionType( [ z.number().int().nonnegative().safe(), z .bigint() .nonnegative() .refine((value) => value <= BigInt(Number.MAX_SAFE_INTEGER)), ], "Expected a nonnegative safe int or a nonnegative safe bigint", ); const solidityTestProfileUserConfigType = z.object({ fsPermissions: z .object({ readWriteFile: z.array(z.string()).optional(), readFile: z.array(z.string()).optional(), writeFile: z.array(z.string()).optional(), dangerouslyReadWriteDirectory: z.array(z.string()).optional(), readDirectory: z.array(z.string()).optional(), dangerouslyWriteDirectory: z.array(z.string()).optional(), }) .optional(), isolate: z.boolean().optional(), ffi: z.boolean().optional(), allowInternalExpectRevert: z.boolean().optional(), from: z.string().startsWith("0x").optional(), txOrigin: z.string().startsWith("0x").optional(), initialBalance: z.bigint().optional(), blockBaseFeePerGas: z.bigint().optional(), coinbase: z.string().startsWith("0x").optional(), blockTimestamp: z.bigint().optional(), prevRandao: z.bigint().optional(), gasLimit: z.bigint().optional(), memoryLimit: nonnegativeSafeIntOrBigInt.optional(), blockGasLimit: z.number().or(z.bigint()).or(z.literal(false)).optional(), transactionGasCap: z.number().or(z.bigint()).or(z.literal(false)).optional(), fuzz: z .object({ failurePersistDir: z.string().optional(), failurePersistFile: z.string().optional(), runs: z.number().optional(), maxTestRejects: z.number().optional(), seed: z.string().optional(), dictionaryWeight: z.number().optional(), includeStorage: z.boolean().optional(), includePushBytes: z.boolean().optional(), showLogs: z.boolean().optional(), timeout: nonnegativeSafeIntOrSafeBigInt.optional(), }) .optional(), forking: z .object({ url: z.optional(sensitiveUrlSchema), blockNumber: z.optional( unionType( [z.number().int().nonnegative().safe(), z.bigint().nonnegative()], "Expected a nonnegative safe int or a nonnegative bigint", ), ), rpcEndpoints: z.record(sensitiveStringSchema).optional(), }) .optional(), invariant: z .object({ failurePersistDir: z.string().optional(), runs: z.number().optional(), depth: z.number().optional(), failOnRevert: z.boolean().optional(), callOverride: z.boolean().optional(), dictionaryWeight: z.number().optional(), includeStorage: z.boolean().optional(), includePushBytes: z.boolean().optional(), shrinkRunLimit: z.number().optional(), timeout: nonnegativeSafeIntOrSafeBigInt.optional(), }) .optional(), eip712Types: z .object({ include: z.array(z.string()).optional(), exclude: z.array(z.string()).optional(), }) .optional(), }); const solidityTestFlatUserConfigType = solidityTestProfileUserConfigType.extend( { profiles: incompatibleFieldType( "This field is incompatible with the flat solidity test config", ), }, ); const solidityTestProfilesUserConfigType = z.object({ profiles: z .record(z.string(), solidityTestProfileUserConfigType) .refine( (profiles) => DEFAULT_TEST_PROFILE in profiles, "The 'default' profile is required when using Solidity test profiles", ) .superRefine((profiles, ctx) => { for (const profileName of Object.keys(profiles)) { if (!TEST_PROFILE_NAME_PATTERN.test(profileName)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [profileName], message: "Invalid profile name. Profile names must start with a letter or a number, and can only contain letters, numbers, underscores and dashes", }); continue; } if (RESERVED_TEST_PROFILE_NAMES.includes(profileName)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [profileName], message: `\`${profileName}\` can't be used as a profile name, as it's an inline test configuration key`, }); } } }), }); const solidityTestUserConfigType = conditionalUnionType( [ [ (data) => isObject(data) && "profiles" in data && Object.keys(data).length === 1, solidityTestProfilesUserConfigType, ], [isObject, solidityTestFlatUserConfigType], ], "Expected a Solidity test config or a `{ profiles: { ... } }` wrapper", ); const userConfigType = z.object({ paths: z .object({ test: conditionalUnionType( [ [isObject, z.object({ solidity: z.string().optional() })], [(data) => typeof data === "string", z.string()], ], "Expected a string or an object with an optional 'solidity' property", ).optional(), }) .optional(), test: z .object({ solidity: solidityTestUserConfigType.optional(), }) .optional(), }); export function resolveSolidityTestForkingConfig( forkingUserConfig: SolidityTestProfileUserConfig["forking"], resolveConfigurationVariable: ConfigurationVariableResolver, ): SolidityTestForkingConfig | undefined { if (forkingUserConfig === undefined) { return undefined; } const resolvedRpcEndpoints: Record = {}; if (forkingUserConfig.rpcEndpoints !== undefined) { for (const [name, url] of Object.entries(forkingUserConfig.rpcEndpoints)) { resolvedRpcEndpoints[name] = resolveConfigurationVariable(url); } } return { ...forkingUserConfig, blockNumber: forkingUserConfig.blockNumber !== undefined ? BigInt(forkingUserConfig.blockNumber) : undefined, url: forkingUserConfig.url !== undefined ? resolveConfigurationVariable(forkingUserConfig.url) : undefined, rpcEndpoints: resolvedRpcEndpoints, }; } export function validateSolidityTestUserConfig( userConfig: unknown, ): HardhatUserConfigValidationError[] { return validateUserConfigZodType(userConfig, userConfigType); } export async function resolveSolidityTestUserConfig( userConfig: HardhatUserConfig, resolvedConfig: HardhatConfig, resolveConfigurationVariable: ConfigurationVariableResolver, ): Promise { let testsPath = userConfig.paths?.tests; // TODO: use isObject when the type narrowing issue is fixed testsPath = typeof testsPath === "object" ? testsPath.solidity : testsPath; testsPath ??= "test"; const rpcCachePath = path.join(resolvedConfig.paths.cache, "edr"); const solidityUserConfig = userConfig.test?.solidity; // The flat config shape is sugar for a single `default` profile. let profilesUserConfig: Record; if (solidityUserConfig !== undefined && "profiles" in solidityUserConfig) { profilesUserConfig = solidityUserConfig.profiles; } else { profilesUserConfig = { [DEFAULT_TEST_PROFILE]: solidityUserConfig ?? {} }; } assertHardhatInvariant( DEFAULT_TEST_PROFILE in profilesUserConfig, "default profile must be present when the profiles wrapper user config is supplied", ); const profiles: Record = {}; for (const [profileName, profileUserConfig] of Object.entries( profilesUserConfig, )) { profiles[profileName] = resolveSolidityTestProfileUserConfig( profileUserConfig, rpcCachePath, resolveConfigurationVariable, ); } return { ...resolvedConfig, paths: { ...resolvedConfig.paths, tests: { ...resolvedConfig.paths.tests, solidity: resolveFromRoot(resolvedConfig.paths.root, testsPath), }, }, test: { ...resolvedConfig.test, solidity: { profiles }, }, }; } function resolveSolidityTestProfileUserConfig( profileUserConfig: SolidityTestProfileUserConfig, rpcCachePath: string, resolveConfigurationVariable: ConfigurationVariableResolver, ): SolidityTestProfileConfig { const { memoryLimit, fuzz, invariant, forking, eip712Types, ...otherProfileUserConfig } = profileUserConfig; return { rpcCachePath, ...otherProfileUserConfig, memoryLimit: memoryLimit !== undefined ? BigInt(memoryLimit) : undefined, fuzz: resolveFuzzConfig(fuzz), invariant: resolveInvariantConfig(invariant), forking: resolveSolidityTestForkingConfig( forking, resolveConfigurationVariable, ), eip712Types: resolveEip712TypesConfig(eip712Types), }; } export function resolveFuzzConfig( fuzzUserConfig: SolidityTestProfileUserConfig["fuzz"] = {}, ): SolidityTestProfileConfig["fuzz"] { const { seed, timeout, ...otherFuzzUserConfig } = fuzzUserConfig; return { ...otherFuzzUserConfig, seed: seed ?? DEFAULT_FUZZ_SEED, ...(timeout !== undefined ? { timeout: Number(timeout) } : {}), }; } export function resolveInvariantConfig( invariantUserConfig: SolidityTestProfileUserConfig["invariant"], ): SolidityTestProfileConfig["invariant"] { if (invariantUserConfig === undefined) { return undefined; } const { timeout, ...otherInvariantUserConfig } = invariantUserConfig; return { ...otherInvariantUserConfig, ...(timeout !== undefined ? { timeout: Number(timeout) } : {}), }; } export function resolveEip712TypesConfig( eip712TypesUserConfig: SolidityTestProfileUserConfig["eip712Types"] = {}, ): SolidityTestProfileConfig["eip712Types"] { return { include: eip712TypesUserConfig.include ?? [], exclude: eip712TypesUserConfig.exclude ?? [], }; }