diff --git a/.changeset/warm-solx-plugin.md b/.changeset/warm-solx-plugin.md new file mode 100644 index 00000000000..b555a98bf1c --- /dev/null +++ b/.changeset/warm-solx-plugin.md @@ -0,0 +1,6 @@ +--- +"@nomicfoundation/hardhat-solx": major +"@nomicfoundation/hardhat-errors": patch +--- + +Add `@nomicfoundation/hardhat-solx` plugin for solx compiler support in test builds ([#8034](https://github.com/NomicFoundation/hardhat/pull/8034)) diff --git a/.peer-bumps.json b/.peer-bumps.json index 47b9d45321b..a8d693abdab 100644 --- a/.peer-bumps.json +++ b/.peer-bumps.json @@ -7,6 +7,11 @@ "packages/config" ], "bumps": [ + { + "package": "@nomicfoundation/hardhat-solx", + "peer": "hardhat", + "reason": "It depends on the new SolidityHooks#downloadCompilers and SolidityHooks#getCompiler hooks" + }, { "package": "@nomicfoundation/hardhat-ignition-viem", "peer": "@nomicfoundation/hardhat-ignition", diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index 401eee1e3a8..f011801c9b6 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -328,6 +328,19 @@ export const ERROR_CATEGORIES: { }, }, }, + HARDHAT_SOLX: { + min: 110000, + max: 119999, + pluginId: "hardhat-solx", + websiteTitle: "Hardhat Solx", + CATEGORIES: { + GENERAL: { + min: 110000, + max: 110099, + websiteSubTitle: "General errors", + }, + }, + }, }; export const ERRORS = { @@ -3063,4 +3076,34 @@ Check the error message for more details and verify your foundry.toml configurat }, }, }, + HARDHAT_SOLX: { + GENERAL: { + UNSUPPORTED_PLATFORM: { + number: 110000, + messageTemplate: `solx is not available for the current platform ({platform}/{arch}). + +solx supports: linux/x64, linux/arm64, darwin (macOS), windows/x64.`, + websiteTitle: "Unsupported platform", + websiteDescription: `The solx compiler is not available for your current operating system and architecture combination. + +solx supports: linux/x64, linux/arm64, darwin (macOS), windows/x64.`, + }, + DOWNLOAD_FAILED: { + number: 110001, + messageTemplate: `Failed to download solx {version} after {attempts} attempts: {reason}`, + websiteTitle: "solx download failed", + websiteDescription: `The solx compiler binary could not be downloaded from the solx releases mirror used by Hardhat. + +Check your internet connection, ensure that the solx releases mirror (https://solx-releases-mirror.hardhat.org) is reachable from your environment, and verify that the requested solx version exists.`, + }, + BINARY_NOT_FOUND: { + number: 110002, + messageTemplate: `solx binary not found at {path}`, + websiteTitle: "solx binary not found", + websiteDescription: `The configured custom path for the solx binary does not exist. + +Verify that the path in your Hardhat config points to a valid solx binary.`, + }, + }, + }, } as const; diff --git a/packages/hardhat-solx/.gitignore b/packages/hardhat-solx/.gitignore new file mode 100644 index 00000000000..0e1706cfcd7 --- /dev/null +++ b/packages/hardhat-solx/.gitignore @@ -0,0 +1,14 @@ +# Node modules +/node_modules + +# Compilation output +/dist + +# pnpm deploy output +/bundle + +# test coverage output +coverage + +# all the tmp folders in the fixture projects +/test/fixture-projects/tmp/ diff --git a/packages/hardhat-solx/.prettierignore b/packages/hardhat-solx/.prettierignore new file mode 100644 index 00000000000..f6ebd3c5cd2 --- /dev/null +++ b/packages/hardhat-solx/.prettierignore @@ -0,0 +1,6 @@ +/node_modules +/dist +/coverage +CHANGELOG.md +/test/fixture-projects/**/artifacts +/test/fixture-projects/**/cache diff --git a/packages/hardhat-solx/LICENSE b/packages/hardhat-solx/LICENSE new file mode 100644 index 00000000000..a9f4e30f797 --- /dev/null +++ b/packages/hardhat-solx/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Nomic Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/hardhat-solx/README.md b/packages/hardhat-solx/README.md new file mode 100644 index 00000000000..f34e452470b --- /dev/null +++ b/packages/hardhat-solx/README.md @@ -0,0 +1,104 @@ +# Hardhat Solx plugin + +This plugin enables the [solx](https://github.com/NomicFoundation/solx) Solidity compiler in Hardhat 3. + +The `solx` compiler is currently experimental and is not ready for production use-cases. We recommend using the compiler for test builds and test execution locally, and continuing to use `solc` for production use-cases (including during deployment for example with `hardhat-ignition` and in your CI). Care should be taken before enabling compilation with `solx` in other build profiles, see configuration flags further below. + +## Installation + +```bash +npm install --save-dev @nomicfoundation/hardhat-solx +``` + +Then add the plugin to your `hardhat.config.ts` and create a `solx` build profile. You must use the build profiles config format, which requires both a `default` and a `solx` profile: + +```typescript +import { defineConfig } from "hardhat/config"; +import hardhatSolx from "@nomicfoundation/hardhat-solx"; + +export default defineConfig({ + plugins: [hardhatSolx], + solidity: { + profiles: { + default: { + version: "0.8.29", + }, + solx: { + type: "solx", + version: "0.8.34", + }, + }, + }, +}); +``` + +The `default` profile uses solc as usual. The `solx` profile uses the solx compiler, identified by `type: "solx"`. Your `.sol` files should have compatible pragmas, for example `pragma solidity ^0.8.29;`. Strict pragmas for unsupported Solidity versions, for example `pragma solidity 0.8.28;`, will currently not compile with this hardhat-solx plugin. See more details below for the currently supported Solidity versions and EVM versions. + +## Usage + +Run tests or compile using the solx-powered build profile: + +```bash +hardhat test --build-profile solx +hardhat build --build-profile solx +``` + +The default profile continues to use solc as usual: + +```bash +hardhat build # uses solc (default profile) +``` + +## Configuration + +### Multi-version example + +You can configure the `solx` profile with multiple compilers. Compilers without `type: "solx"` will use solc: + +```typescript +export default defineConfig({ + plugins: [hardhatSolx], + solidity: { + profiles: { + default: { + compilers: [{ version: "0.8.34" }, { version: "0.8.20" }], + }, + solx: { + compilers: [ + { type: "solx", version: "0.8.34" }, + { version: "0.8.20" }, // uses solc, solx doesn't support this version + ], + }, + }, + }, +}); +``` + +### Options + +- `dangerouslyAllowSolxInProduction` (`boolean`, default: `false`), allows compiler type `"solx"` in build profiles other than `solx`. By default, using `type: "solx"` in any other profile (e.g. `default`, `production`) will produce a validation error. + +```typescript +export default defineConfig({ + plugins: [hardhatSolx], + solidity: { + profiles: { + default: { + type: "solx", // returns a validation error. + version: "0.8.34", + }, + }, + }, + solx: { + dangerouslyAllowSolxInProduction: false, // default false, switching this to true will allow `type: "solx"` on the default profile. + }, +}); +``` + +### Supported Solidity versions + +solx maps each Solidity version to a specific solx binary version internally. Currently supported: `0.8.33` (solx 0.1.3), `0.8.34` (solx 0.1.4). + +### EVM version support + +solx supports EVM versions `cancun`, `prague`, and `osaka`. Using an older EVM target (e.g., `paris`, `shanghai`) with compiler type `"solx"` will result in a validation error. diff --git a/packages/hardhat-solx/eslint.config.js b/packages/hardhat-solx/eslint.config.js new file mode 100644 index 00000000000..6372cd70d65 --- /dev/null +++ b/packages/hardhat-solx/eslint.config.js @@ -0,0 +1,3 @@ +import { createConfig } from "../config/eslint.config.js"; + +export default createConfig(import.meta.filename); diff --git a/packages/hardhat-solx/package.json b/packages/hardhat-solx/package.json new file mode 100644 index 00000000000..42d5ee74e09 --- /dev/null +++ b/packages/hardhat-solx/package.json @@ -0,0 +1,70 @@ +{ + "name": "@nomicfoundation/hardhat-solx", + "private": true, + "version": "2.0.0", + "description": "Hardhat plugin for using solx compiler in test builds", + "homepage": "https://github.com/NomicFoundation/hardhat/tree/main/packages/hardhat-solx", + "repository": { + "type": "git", + "url": "https://github.com/NomicFoundation/hardhat", + "directory": "packages/hardhat-solx" + }, + "author": "Nomic Foundation", + "license": "MIT", + "type": "module", + "types": "dist/src/index.d.ts", + "exports": { + ".": "./dist/src/index.js" + }, + "keywords": [ + "ethereum", + "smart-contracts", + "hardhat", + "solx", + "compiler" + ], + "scripts": { + "lint": "pnpm prettier --check && pnpm eslint", + "lint:fix": "pnpm prettier --write && pnpm eslint --fix", + "eslint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "prettier": "prettier \"**/*.{ts,js,md,json}\"", + "test": "node --import tsx/esm --test --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "test:only": "node --import tsx/esm --test --test-only --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "test:coverage": "c8 --reporter html --reporter text --all --exclude test --exclude \"src/**/{types,type-extensions}.ts\" --src src node --import tsx/esm --test --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "pretest": "pnpm build", + "pretest:only": "pnpm build", + "build": "tsc --build .", + "prepublishOnly": "pnpm build", + "clean": "rimraf dist" + }, + "files": [ + "dist/src/", + "src/", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], + "devDependencies": { + "@nomicfoundation/hardhat-node-test-reporter": "workspace:^3.0.0", + "@nomicfoundation/hardhat-test-utils": "workspace:^", + "@types/debug": "^4.1.7", + "@types/node": "^22.0.0", + "c8": "^9.1.0", + "eslint": "9.25.1", + "prettier": "3.2.5", + "rimraf": "^5.0.5", + "tsx": "^4.19.3", + "typescript": "~5.8.0", + "hardhat": "workspace:^3.1.6" + }, + "dependencies": { + "@nomicfoundation/hardhat-errors": "workspace:^3.0.3", + "@nomicfoundation/hardhat-utils": "workspace:^3.0.5", + "@nomicfoundation/hardhat-zod-utils": "workspace:^3.0.0", + "debug": "^4.3.2", + "zod": "^3.23.8" + }, + "peerDependencies": { + "hardhat": "workspace:^3.1.6" + } +} diff --git a/packages/hardhat-solx/src/index.ts b/packages/hardhat-solx/src/index.ts new file mode 100644 index 00000000000..a44286e73f9 --- /dev/null +++ b/packages/hardhat-solx/src/index.ts @@ -0,0 +1,14 @@ +import type { HardhatPlugin } from "hardhat/types/plugins"; + +import "./type-extensions.js"; + +const hardhatSolxPlugin: HardhatPlugin = { + id: "hardhat-solx", + npmPackage: "@nomicfoundation/hardhat-solx", + hookHandlers: { + config: () => import("./internal/hook-handlers/config.js"), + solidity: () => import("./internal/hook-handlers/solidity.js"), + }, +}; + +export default hardhatSolxPlugin; diff --git a/packages/hardhat-solx/src/internal/constants.ts b/packages/hardhat-solx/src/internal/constants.ts new file mode 100644 index 00000000000..e08e84929eb --- /dev/null +++ b/packages/hardhat-solx/src/internal/constants.ts @@ -0,0 +1,30 @@ +import type { SolidityCompilerType } from "hardhat/types/config"; + +/** + * The compiler type identifier registered by this plugin. + * Typed as SolidityCompilerType for type-safe comparisons. + */ +export const SOLX_COMPILER_TYPE: SolidityCompilerType = "solx"; + +export const SOLX_RELEASES_BASE_URL = + "https://solx-releases-mirror.hardhat.org"; + +export const SUPPORTED_SOLX_EVM_VERSIONS: readonly string[] = [ + "cancun", + "prague", + "osaka", +] as const; + +export const DEFAULT_SOLX_SETTINGS: Record = { + viaIR: false, + LLVMOptimization: "1", +}; + +/** + * Maps Solidity compiler versions to the solx version that embeds them. + * Only stable solx releases are included. + */ +export const SOLIDITY_TO_SOLX_VERSION_MAP: Record = { + "0.8.33": "0.1.3", + "0.8.34": "0.1.4", +}; diff --git a/packages/hardhat-solx/src/internal/downloader.ts b/packages/hardhat-solx/src/internal/downloader.ts new file mode 100644 index 00000000000..7e6fa6c3d5c --- /dev/null +++ b/packages/hardhat-solx/src/internal/downloader.ts @@ -0,0 +1,184 @@ +import path from "node:path"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { ensureError } from "@nomicfoundation/hardhat-utils/error"; +import { + chmod, + exists, + readBinaryFile, + remove, +} from "@nomicfoundation/hardhat-utils/fs"; +import { getCacheDir } from "@nomicfoundation/hardhat-utils/global-dir"; +import { download, getRequest } from "@nomicfoundation/hardhat-utils/request"; +import { MultiProcessMutex } from "@nomicfoundation/hardhat-utils/synchronization"; +import debug from "debug"; + +import { SOLX_RELEASES_BASE_URL } from "./constants.js"; +import { getSolxAssetName } from "./platform.js"; + +const log = debug("hardhat:solx:downloader"); + +const DOWNLOAD_RETRY_COUNT = 3; +const DOWNLOAD_RETRY_DELAY_MS = 2000; + +/** + * Returns the deterministic path where a solx binary for the given version + * would be cached. This is a pure function — it does not check whether the + * binary exists on disk. + */ +export async function getSolxBinaryPath(solxVersion: string): Promise { + const assetName = getSolxAssetName(solxVersion); + const globalCacheDir = await getCacheDir(); + return path.join( + globalCacheDir, + "compilers-v3", + `solx-v${solxVersion}`, + assetName, + ); +} + +/** + * Verifies the SHA-256 checksum of a downloaded binary against a `.sha256` + * sidecar file on the mirror. If the sidecar file is not available (e.g. for + * stable releases), verification is skipped. If it is available and the + * checksum doesn't match, the downloaded file is deleted and false is returned. + */ +async function verifyChecksum( + binaryPath: string, + checksumUrl: string, +): Promise { + try { + const response = await getRequest(checksumUrl); + + if (response.statusCode < 200 || response.statusCode >= 300) { + log( + `No .sha256 sidecar file at ${checksumUrl} (status ${response.statusCode}), skipping verification`, + ); + return true; + } + + // The sidecar file contains the hex-encoded SHA-256 hash, possibly with + // a filename suffix (like sha256sum output). We only need the hash part. + const text = (await response.body.text()).trim(); + const expectedHash = text.split(/\s+/)[0].toLowerCase(); + + const { sha256 } = await import("@nomicfoundation/hardhat-utils/crypto"); + const { bytesToHexString } = await import( + "@nomicfoundation/hardhat-utils/hex" + ); + + const binaryContents = await readBinaryFile(binaryPath); + const actualHash = bytesToHexString(await sha256(binaryContents)) + .slice(2) // remove 0x prefix + .toLowerCase(); + + if (expectedHash !== actualHash) { + log( + `SHA-256 mismatch for ${binaryPath}: expected ${expectedHash}, got ${actualHash}`, + ); + await remove(binaryPath); + return false; + } + + log(`SHA-256 checksum verified for ${binaryPath}`); + return true; + } catch (error) { + ensureError(error); + log( + `Could not verify checksum from ${checksumUrl}: ${error.message}, skipping verification`, + ); + return true; + } +} + +/** + * Downloads the solx binary for the given version if not already cached. + * Returns the path to the binary on disk. + * + * @param solxVersion - The solx version to download (e.g. "0.1.3") + * @param downloadFunction - Optional injectable download function for testing. + * Defaults to the real `download` from `@nomicfoundation/hardhat-utils/request`. + */ +export async function downloadSolx( + solxVersion: string, + downloadFunction: typeof download = download, +): Promise { + const binaryPath = await getSolxBinaryPath(solxVersion); + + // Return cached binary if it already exists + if (await exists(binaryPath)) { + log(`Using cached solx binary at ${binaryPath}`); + return binaryPath; + } + + const globalCacheDir = await getCacheDir(); + const mutex = new MultiProcessMutex( + path.join(globalCacheDir, `solx-download-${solxVersion}`), + ); + const assetName = getSolxAssetName(solxVersion); + const url = `${SOLX_RELEASES_BASE_URL}/${assetName}`; + log(`Downloading solx ${solxVersion} from ${url}`); + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= DOWNLOAD_RETRY_COUNT; attempt++) { + // Use a mutex per retry iteration so other processes can proceed + // between retries + const result = await mutex.use(async () => { + // Check if another process downloaded it while we waited for the mutex + if (await exists(binaryPath)) { + log( + `Using cached solx binary at ${binaryPath} (downloaded by another process)`, + ); + return binaryPath; + } + + try { + await downloadFunction(url, binaryPath); + + // Verify SHA-256 checksum if a sidecar file is available + const checksumUrl = `${SOLX_RELEASES_BASE_URL}/${assetName}.sha256`; + const checksumValid = await verifyChecksum(binaryPath, checksumUrl); + if (!checksumValid) { + lastError = new Error("SHA-256 checksum verification failed"); + return undefined; + } + + // Set executable permission on Unix + if (process.platform !== "win32") { + await chmod(binaryPath, 0o755); + } + + log(`Successfully downloaded solx ${solxVersion}`); + return binaryPath; + } catch (error) { + ensureError(error); + lastError = error; + log( + `Download attempt ${attempt}/${DOWNLOAD_RETRY_COUNT} failed: ${lastError.message}`, + ); + return undefined; + } + }); + + if (result !== undefined) { + return result; + } + + if (attempt < DOWNLOAD_RETRY_COUNT) { + await new Promise((resolve) => + setTimeout(resolve, DOWNLOAD_RETRY_DELAY_MS), + ); + } + } + + throw new HardhatError( + HardhatError.ERRORS.HARDHAT_SOLX.GENERAL.DOWNLOAD_FAILED, + { + version: solxVersion, + attempts: DOWNLOAD_RETRY_COUNT.toString(), + reason: lastError?.message ?? "unknown error", + }, + lastError, + ); +} diff --git a/packages/hardhat-solx/src/internal/hook-handlers/config.ts b/packages/hardhat-solx/src/internal/hook-handlers/config.ts new file mode 100644 index 00000000000..554665ffd25 --- /dev/null +++ b/packages/hardhat-solx/src/internal/hook-handlers/config.ts @@ -0,0 +1,253 @@ +import type { + ConfigurationVariableResolver, + HardhatConfig, + HardhatUserConfig, + SolxConfig, +} from "hardhat/types/config"; +import type { + ConfigHooks, + HardhatConfigValidationError, + HardhatUserConfigValidationError, +} from "hardhat/types/hooks"; + +import { isObject } from "@nomicfoundation/hardhat-utils/lang"; +import { + conditionalUnionType, + validateUserConfigZodType, +} from "@nomicfoundation/hardhat-zod-utils"; +import debug from "debug"; +import { z } from "zod"; + +import { + SOLIDITY_TO_SOLX_VERSION_MAP, + SOLX_COMPILER_TYPE, + SUPPORTED_SOLX_EVM_VERSIONS, +} from "../constants.js"; + +const log = debug("hardhat:solx:hook-handlers:config"); + +// These zod types need to be aligned in shape with the ones of the solidity +// builtin plugin, but don't need to revalidate everything. + +const SUPPORTED_VERSIONS = Array.from( + Object.keys(SOLIDITY_TO_SOLX_VERSION_MAP), +); + +const supportedEvmVersionsType = z + .string() + .refine((val) => SUPPORTED_SOLX_EVM_VERSIONS.includes(val), { + message: `Solx only supports EVM versions: ${SUPPORTED_SOLX_EVM_VERSIONS.join(", ")}`, + }); + +const solxSolidityCompilerUserConfigType = z + .object({ + version: z.string(), + settings: z + .object({ + evmVersion: supportedEvmVersionsType.optional(), + }) + .passthrough() + .optional(), + }) + .passthrough() + .refine( + (data) => + (typeof data.path === "string" && data.path.length > 0) || + SUPPORTED_VERSIONS.includes(data.version), + { + message: `Solx only supports versions: ${SUPPORTED_VERSIONS.join(", ")}`, + path: ["version"], + }, + ); + +const solidityCompilerUserConfigType = conditionalUnionType( + [ + [ + (data) => isObject(data) && "type" in data && data.type === "solx", + solxSolidityCompilerUserConfigType, + ], + [(_data) => true, z.any()], + ], + "Expected a valid compiler configuration", +); + +const singleVersionSolidityUserConfigType = conditionalUnionType( + [ + [ + (data) => isObject(data) && "type" in data && data.type === "solx", + solxSolidityCompilerUserConfigType, + ], + [(_data) => true, z.any()], + ], + "Expected a valid single-version Solidity configuration", +); + +const multiVersionSolidityUserConfigType = z.object({ + compilers: z.array(solidityCompilerUserConfigType).nonempty(), + overrides: z.record(z.string(), solidityCompilerUserConfigType).optional(), +}); + +const singleVersionBuildProfileUserConfigType = conditionalUnionType( + [ + [ + (data) => isObject(data) && "type" in data && data.type === "solx", + solxSolidityCompilerUserConfigType, + ], + [(_data) => true, z.any()], + ], + "Expected a valid compiler configuration", +); + +const multiVersionBuildProfileUserConfigType = z.object({ + compilers: z.array(solidityCompilerUserConfigType).nonempty(), + overrides: z.record(z.string(), solidityCompilerUserConfigType).optional(), +}); + +const buildProfilesSolidityUserConfigType = z.object({ + profiles: z.record( + z.string(), + conditionalUnionType( + [ + [ + (data) => isObject(data) && "version" in data, + singleVersionBuildProfileUserConfigType, + ], + [ + (data) => isObject(data) && "compilers" in data, + multiVersionBuildProfileUserConfigType, + ], + ], + "Expected an object configuring one or more versions of Solidity", + ), + ), +}); + +const solidityUserConfigType = conditionalUnionType( + [ + [ + (data) => isObject(data) && "version" in data, + singleVersionSolidityUserConfigType, + ], + [ + (data) => isObject(data) && "compilers" in data, + multiVersionSolidityUserConfigType, + ], + [ + (data) => isObject(data) && "profiles" in data, + buildProfilesSolidityUserConfigType, + ], + [(_data) => true, z.any()], + ], + "Expected a version string, an array of version strings, or an object configuring one or more versions of Solidity or multiple build profiles", +); + +const solxUserConfigType = z.object({ + solidity: solidityUserConfigType.optional(), + solx: z + .object({ + dangerouslyAllowSolxInProduction: z.boolean().optional(), + }) + .optional(), +}); + +export default async (): Promise> => ({ + validateUserConfig, + resolveUserConfig, + validateResolvedConfig, +}); + +export async function validateUserConfig( + userConfig: HardhatUserConfig, +): Promise { + return validateUserConfigZodType(userConfig, solxUserConfigType); +} + +export async function resolveUserConfig( + userConfig: HardhatUserConfig, + resolveConfigurationVariable: ConfigurationVariableResolver, + next: ( + nextUserConfig: HardhatUserConfig, + nextResolveConfigurationVariable: ConfigurationVariableResolver, + ) => Promise, +): Promise { + const resolvedConfig = await next(userConfig, resolveConfigurationVariable); + + return { + ...resolvedConfig, + solidity: { + ...resolvedConfig.solidity, + registeredCompilerTypes: + resolvedConfig.solidity.registeredCompilerTypes.includes( + SOLX_COMPILER_TYPE, + ) + ? resolvedConfig.solidity.registeredCompilerTypes + : [ + ...resolvedConfig.solidity.registeredCompilerTypes, + SOLX_COMPILER_TYPE, + ], + }, + solx: resolveSolxConfig(userConfig.solx), + }; +} + +export async function validateResolvedConfig( + resolvedConfig: HardhatConfig, +): Promise { + const errors: HardhatConfigValidationError[] = []; + + // Check that the user defined a "solx" build profile + if (resolvedConfig.solidity.profiles.solx === undefined) { + errors.push({ + path: ["solidity"], + message: + 'The hardhat-solx plugin has been installed, but no "solx" build profile was found in the Solidity configuration. Please read the plugin documentation for information on how to create a "solx" build profile.', + }); + } + + // Check that type: "solx" is not used in non-solx profiles + if (resolvedConfig.solx.dangerouslyAllowSolxInProduction) { + log( + "Skipping non-solx profile validation: dangerouslyAllowSolxInProduction is true", + ); + return errors; + } + + for (const [profileName, profile] of Object.entries( + resolvedConfig.solidity.profiles, + )) { + if (profileName === "solx") { + continue; + } + + const solxInOtherProfileMessage = `Compiler type "solx" is only supported in the "solx" build profile. Remove type: "solx" from the "${profileName}" profile compilers, or set solx.dangerouslyAllowSolxInProduction in the plugin config.`; + + for (const [i, compiler] of profile.compilers.entries()) { + if (compiler.type === SOLX_COMPILER_TYPE) { + errors.push({ + path: ["solidity", "profiles", profileName, "compilers", i, "type"], + message: solxInOtherProfileMessage, + }); + } + } + + for (const [key, override] of Object.entries(profile.overrides)) { + if (override.type === SOLX_COMPILER_TYPE) { + errors.push({ + path: ["solidity", "profiles", profileName, "overrides", key, "type"], + message: solxInOtherProfileMessage, + }); + } + } + } + + return errors; +} + +function resolveSolxConfig(userConfig?: { + dangerouslyAllowSolxInProduction?: boolean; +}): SolxConfig { + return { + dangerouslyAllowSolxInProduction: + userConfig?.dangerouslyAllowSolxInProduction ?? false, + }; +} diff --git a/packages/hardhat-solx/src/internal/hook-handlers/solidity.ts b/packages/hardhat-solx/src/internal/hook-handlers/solidity.ts new file mode 100644 index 00000000000..dcb480b4b76 --- /dev/null +++ b/packages/hardhat-solx/src/internal/hook-handlers/solidity.ts @@ -0,0 +1,134 @@ +import type { SolidityHooks } from "hardhat/types/hooks"; + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { + HardhatError, + assertHardhatInvariant, +} from "@nomicfoundation/hardhat-errors"; +import { exists } from "@nomicfoundation/hardhat-utils/fs"; +import debug from "debug"; + +import { + DEFAULT_SOLX_SETTINGS, + SOLIDITY_TO_SOLX_VERSION_MAP, + SOLX_COMPILER_TYPE, +} from "../constants.js"; +import { downloadSolx, getSolxBinaryPath } from "../downloader.js"; +import { SolxCompiler } from "../solx-compiler.js"; + +const log = debug("hardhat:solx:hook-handlers:solidity"); + +const execFileAsync = promisify(execFile); + +// NOTE: This function is exported for testing purposes +export function parseSolxVersion(versionOutput: string): string { + const firstLine = versionOutput.split("\n")[0]; + const match = firstLine.match(/ v(\d+\.\d+\.\d+(?:-[\w.]+)?)/); + assertHardhatInvariant( + match !== null, + `Could not parse solx version from --version output: ${versionOutput}`, + ); + + return match[1]; +} + +async function getSolxVersionFromBinary(binaryPath: string): Promise { + const { stdout } = await execFileAsync(binaryPath, ["--version"]); + log(`--version output: ${stdout}`); + return parseSolxVersion(stdout); +} + +export default async (): Promise> => ({ + downloadCompilers: async (_context, compilerConfigs, quiet) => { + const solxConfigs = compilerConfigs.filter( + (c) => c.type === SOLX_COMPILER_TYPE, + ); + + if (solxConfigs.length === 0) { + return; + } + + // Collect unique solx versions to download (skip configs with custom path) + const solxVersions = new Set(); + for (const config of solxConfigs) { + if (config.path !== undefined) { + log( + `Skipping download for Solidity ${config.version}: custom path provided`, + ); + continue; + } + const solxVersion = SOLIDITY_TO_SOLX_VERSION_MAP[config.version]; + if (solxVersion !== undefined) { + solxVersions.add(solxVersion); + } + } + + await Promise.all( + [...solxVersions].map(async (solxVersion) => { + const binaryPath = await getSolxBinaryPath(solxVersion); + if (await exists(binaryPath)) { + log(`solx ${solxVersion} already cached at ${binaryPath}`); + return; + } + + if (!quiet) { + console.log(`Downloading solx ${solxVersion}`); + } + + const solxPath = await downloadSolx(solxVersion); + log(`Downloaded solx ${solxVersion} to ${solxPath}`); + }), + ); + }, + + getCompiler: async (context, compilerConfig, next) => { + if (compilerConfig.type !== SOLX_COMPILER_TYPE) { + return next(context, compilerConfig); + } + + // Honor custom path — skip version lookup and download + if (compilerConfig.path !== undefined) { + if (!(await exists(compilerConfig.path))) { + throw new HardhatError( + HardhatError.ERRORS.HARDHAT_SOLX.GENERAL.BINARY_NOT_FOUND, + { path: compilerConfig.path }, + ); + } + + const customSolxVersion = await getSolxVersionFromBinary( + compilerConfig.path, + ); + + log( + `Creating SolxCompiler with custom path for Solidity ${compilerConfig.version} (solx ${customSolxVersion}) at ${compilerConfig.path}`, + ); + + return new SolxCompiler( + customSolxVersion, + compilerConfig.path, + DEFAULT_SOLX_SETTINGS, + ); + } + + const solxVersion = SOLIDITY_TO_SOLX_VERSION_MAP[compilerConfig.version]; + assertHardhatInvariant( + solxVersion !== undefined, + `No solx version mapping for Solidity ${compilerConfig.version} — this should have been caught by config validation`, + ); + + const binaryPath = await getSolxBinaryPath(solxVersion); + + assertHardhatInvariant( + await exists(binaryPath), + `solx binary not found at ${binaryPath} — downloadCompilers should have been called first`, + ); + + log( + `Creating SolxCompiler for Solidity ${compilerConfig.version} (solx ${solxVersion}) at ${binaryPath}`, + ); + + return new SolxCompiler(solxVersion, binaryPath, DEFAULT_SOLX_SETTINGS); + }, +}); diff --git a/packages/hardhat-solx/src/internal/platform.ts b/packages/hardhat-solx/src/internal/platform.ts new file mode 100644 index 00000000000..3891b7ce98a --- /dev/null +++ b/packages/hardhat-solx/src/internal/platform.ts @@ -0,0 +1,39 @@ +import os from "node:os"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; + +/** + * Returns the platform-specific base name for the solx binary (without version suffix). + * The full asset name is `${baseName}-v${version}` (or `.exe` on Windows). + * + * Actual GitHub release assets (e.g., for v0.1.3): + * solx-linux-amd64-gnu-v0.1.3 + * solx-linux-arm64-gnu-v0.1.3 + * solx-macosx-v0.1.3 (universal binary) + * solx-windows-amd64-gnu-v0.1.3.exe + */ +export function getSolxBinaryBaseName(): string { + const platform = os.platform(); + const arch = os.arch(); + + if (platform === "linux" && arch === "x64") return "solx-linux-amd64-gnu"; + if (platform === "linux" && arch === "arm64") return "solx-linux-arm64-gnu"; + if (platform === "darwin") return "solx-macosx"; + if (platform === "win32" && arch === "x64") return "solx-windows-amd64-gnu"; + + throw new HardhatError( + HardhatError.ERRORS.HARDHAT_SOLX.GENERAL.UNSUPPORTED_PLATFORM, + { + platform, + arch, + }, + ); +} + +export function getSolxAssetName(version: string): string { + const baseName = getSolxBinaryBaseName(); + if (process.platform === "win32") { + return `${baseName}-v${version}.exe`; + } + return `${baseName}-v${version}`; +} diff --git a/packages/hardhat-solx/src/internal/solx-compiler.ts b/packages/hardhat-solx/src/internal/solx-compiler.ts new file mode 100644 index 00000000000..91e98fcd611 --- /dev/null +++ b/packages/hardhat-solx/src/internal/solx-compiler.ts @@ -0,0 +1,46 @@ +import type { + Compiler, + CompilerInput, + CompilerOutput, +} from "hardhat/types/solidity"; + +import { spawnCompile as defaultSpawnCompile } from "hardhat/internal/solidity"; + +export class SolxCompiler implements Compiler { + public readonly version: string; + public readonly longVersion: string; + public readonly compilerPath: string; + public readonly isSolcJs: boolean = false; + + readonly #extraSettings: Record; + readonly #spawnCompile: typeof defaultSpawnCompile; + + constructor( + solxVersion: string, + compilerPath: string, + extraSettings: Record = {}, + spawnCompile: typeof defaultSpawnCompile = defaultSpawnCompile, + ) { + this.version = solxVersion; + this.longVersion = `${solxVersion}+solx`; + this.compilerPath = compilerPath; + this.#extraSettings = extraSettings; + this.#spawnCompile = spawnCompile; + } + + public async compile(input: CompilerInput): Promise { + const args = ["--standard-json", "--no-import-callback"]; + + // Merge default solx settings with user settings. User settings take + // precedence, allowing overrides of viaIR, LLVMOptimization, etc. + const modifiedInput: CompilerInput = { + ...input, + settings: { + ...this.#extraSettings, + ...input.settings, + }, + }; + + return this.#spawnCompile(this.compilerPath, args, modifiedInput); + } +} diff --git a/packages/hardhat-solx/src/type-extensions.ts b/packages/hardhat-solx/src/type-extensions.ts new file mode 100644 index 00000000000..470c933dfed --- /dev/null +++ b/packages/hardhat-solx/src/type-extensions.ts @@ -0,0 +1,53 @@ +import "hardhat/types/config"; + +declare module "hardhat/types/config" { + export interface SolidityCompilerTypeDefinitions { + solx: true; + } + + export interface SolxSolidityCompilerUserConfig + extends CommonSolidityCompilerUserConfig { + type: "solx"; + } + + export interface SolidityCompilerUserConfigPerType { + solx: SolxSolidityCompilerUserConfig; + } + + export interface SolxSolidityCompilerConfig + extends CommonSolidityCompilerConfig { + type: "solx"; + } + + export interface SolidityCompilerConfigPerType { + solx: SolxSolidityCompilerConfig; + } + + export interface SolxSingleVersionSolidityUserConfig + extends SolxSolidityCompilerUserConfig, + CommonSolidityUserConfig {} + + export interface SingleVersionSolidityUserConfigPerType { + solx: SolxSingleVersionSolidityUserConfig; + } + + export interface SolxUserConfig { + /** + * Allow compiler type `"solx"` in the production build profile. + * By default, solx in production is rejected as a safeguard. + */ + dangerouslyAllowSolxInProduction?: boolean; + } + + export interface SolxConfig { + dangerouslyAllowSolxInProduction: boolean; + } + + export interface HardhatUserConfig { + solx?: SolxUserConfig; + } + + export interface HardhatConfig { + solx: SolxConfig; + } +} diff --git a/packages/hardhat-solx/test/config.ts b/packages/hardhat-solx/test/config.ts new file mode 100644 index 00000000000..f36e5dc4369 --- /dev/null +++ b/packages/hardhat-solx/test/config.ts @@ -0,0 +1,559 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- test */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + resolveUserConfig, + validateResolvedConfig, + validateUserConfig, +} from "../src/internal/hook-handlers/config.js"; + +describe("hardhat-solx plugin config validation", () => { + it("accepts valid config with dangerouslyAllowSolxInProduction", async () => { + const errors = await validateUserConfig({ + solx: { + dangerouslyAllowSolxInProduction: true, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts empty plugin config", async () => { + const errors = await validateUserConfig({ + solx: {}, + }); + assert.deepEqual(errors, []); + }); + + it("accepts config without plugin config key", async () => { + const errors = await validateUserConfig({}); + assert.deepEqual(errors, []); + }); + + it("rejects invalid dangerouslyAllowSolxInProduction type", async () => { + const errors = await validateUserConfig({ + solx: { dangerouslyAllowSolxInProduction: "yes" as any }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + }); + + it("rejects non-boolean dangerouslyAllowSolxInProduction", async () => { + const errors = await validateUserConfig({ + solx: { + dangerouslyAllowSolxInProduction: 1 as any, + }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + }); +}); + +describe("hardhat-solx plugin config resolution", () => { + function makeNext(profiles: Record) { + return async (config: any, _resolve: any) => ({ + ...config, + solidity: { + profiles, + npmFilesToBuild: [], + registeredCompilerTypes: ["solc"], + }, + }); + } + + it("resolves with defaults when no plugin config provided", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: {}, + }, + }), + ); + + assert.equal(resolvedConfig.solx.dangerouslyAllowSolxInProduction, false); + }); + + it("resolves dangerouslyAllowSolxInProduction from user config", async () => { + const resolvedConfig = await resolveUserConfig( + { solx: { dangerouslyAllowSolxInProduction: true } }, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: {}, + }, + }), + ); + + assert.equal(resolvedConfig.solx.dangerouslyAllowSolxInProduction, true); + }); + + it("registers 'solx' as a compiler type", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: {}, + }, + }), + ); + + assert.ok( + resolvedConfig.solidity.registeredCompilerTypes.includes("solx"), + "registeredCompilerTypes should contain 'solx'", + ); + }); + + it("does not inject any additional profiles", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: {}, + }, + }), + ); + + const profileNames = Object.keys(resolvedConfig.solidity.profiles); + assert.deepEqual(profileNames, ["default"]); + }); +}); + +describe("hardhat-solx EVM version validation", () => { + it("rejects type: 'solx' with pre-cancun evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { + version: "0.8.33", + type: "solx", + settings: { evmVersion: "paris" }, + }, + ], + }, + }, + }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + assert.ok( + errors.some((e) => e.message.includes("EVM versions")), + `Expected EVM version error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("rejects type: 'solx' with shanghai evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { + version: "0.8.33", + type: "solx", + settings: { evmVersion: "shanghai" }, + }, + ], + }, + }, + }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + }); + + it("accepts type: 'solx' with cancun evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { + version: "0.8.33", + type: "solx", + settings: { evmVersion: "cancun" }, + }, + ], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts type: 'solx' with prague evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { + version: "0.8.33", + type: "solx", + settings: { evmVersion: "prague" }, + }, + ], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts type: 'solx' with osaka evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { + version: "0.8.33", + type: "solx", + settings: { evmVersion: "osaka" }, + }, + ], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts type: 'solx' without evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [{ version: "0.8.33", type: "solx" }], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("ignores evmVersion on non-solx compiler entries", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { + version: "0.8.33", + settings: { evmVersion: "paris" }, + }, + ], + }, + }, + }, + }); + const evmErrors = errors.filter((e) => e.message.includes("EVM versions")); + assert.deepEqual(evmErrors, []); + }); + + it("reports errors for overrides with unsupported evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [{ version: "0.8.33" }], + overrides: { + "contracts/Old.sol": { + version: "0.8.33", + type: "solx", + settings: { evmVersion: "london" }, + }, + }, + }, + }, + }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + assert.ok( + errors[0].path.includes("overrides"), + `Error path should include 'overrides', got: ${JSON.stringify(errors[0].path)}`, + ); + }); +}); + +describe("hardhat-solx Solidity version validation", () => { + it("rejects type: 'solx' with unsupported Solidity version", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [{ version: "0.8.28", type: "solx" }], + }, + }, + }, + }); + assert.ok( + errors.some((e) => e.message.includes("Solx only supports versions")), + `Expected Solidity version error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("accepts type: 'solx' with supported Solidity version 0.8.33", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [{ version: "0.8.33", type: "solx" }], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Solx only supports versions"), + ); + assert.deepEqual(versionErrors, []); + }); + + it("accepts type: 'solx' with supported version and custom path", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { version: "0.8.33", type: "solx", path: "/tmp/solx-custom" }, + ], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Solx only supports versions"), + ); + assert.deepEqual(versionErrors, []); + }); + + it("accepts type: 'solx' with unsupported version when path is set", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [ + { version: "0.8.35", type: "solx", path: "/tmp/solx-nightly" }, + ], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Solx only supports versions"), + ); + assert.deepEqual(versionErrors, []); + }); + + it("rejects unsupported version when path is empty string", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + solx: { + compilers: [{ version: "0.8.35", type: "solx", path: "" }], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Solx only supports versions"), + ); + assert.ok( + versionErrors.length > 0, + "Expected version validation error for empty path", + ); + }); +}); + +describe("hardhat-solx resolved config validation", () => { + function makeResolvedConfig( + profiles: Record, + opts?: { dangerouslyAllowSolxInProduction?: boolean }, + ): any { + return { + solidity: { + profiles, + npmFilesToBuild: [], + registeredCompilerTypes: ["solc", "solx"], + }, + solx: { + dangerouslyAllowSolxInProduction: + opts?.dangerouslyAllowSolxInProduction ?? false, + }, + }; + } + + it("errors when no 'solx' build profile exists", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: {}, + }, + }), + ); + assert.ok(errors.length > 0, "Should have validation errors"); + assert.ok( + errors.some((e) => e.message.includes('no "solx" build profile')), + `Expected missing solx profile error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("passes when 'solx' build profile exists", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: {}, + }, + solx: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + }), + ); + assert.deepEqual(errors, []); + }); + + it("errors when type: 'solx' appears in a non-solx profile", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + solx: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + }), + ); + assert.ok( + errors.some((e) => + e.message.includes('only supported in the "solx" build profile'), + ), + `Expected non-solx profile error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + assert.ok( + errors.some((e) => e.path.includes("default")), + `Error path should reference 'default' profile`, + ); + }); + + it("errors when type: 'solx' appears in non-solx profile overrides", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: { + "MyContract.sol": { version: "0.8.33", type: "solx", settings: {} }, + }, + }, + solx: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + }), + ); + assert.ok( + errors.some((e) => + e.message.includes('only supported in the "solx" build profile'), + ), + `Expected non-solx profile error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + assert.ok( + errors.some((e) => e.path.includes("overrides")), + `Error path should include 'overrides'`, + ); + }); + + it("allows type: 'solx' in non-solx profiles with dangerouslyAllowSolxInProduction", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig( + { + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + solx: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + }, + { dangerouslyAllowSolxInProduction: true }, + ), + ); + assert.deepEqual(errors, []); + }); + + it("allows type: 'solx' in the solx profile", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", settings: {} }], + overrides: {}, + }, + solx: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + }), + ); + assert.deepEqual(errors, []); + }); + + it("still requires solx profile even with dangerouslyAllowSolxInProduction", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig( + { + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.33", type: "solx", settings: {} }], + overrides: {}, + }, + }, + { dangerouslyAllowSolxInProduction: true }, + ), + ); + assert.ok( + errors.some((e) => e.message.includes('no "solx" build profile')), + `Should still require solx profile, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); +}); diff --git a/packages/hardhat-solx/test/fixture-projects/simple/contracts/Greeter.sol b/packages/hardhat-solx/test/fixture-projects/simple/contracts/Greeter.sol new file mode 100644 index 00000000000..0ab5ca745a3 --- /dev/null +++ b/packages/hardhat-solx/test/fixture-projects/simple/contracts/Greeter.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Greeter { + string private greeting; + + constructor(string memory _greeting) { + greeting = _greeting; + } + + function greet() public view returns (string memory) { + return greeting; + } + + function setGreeting(string memory _greeting) public { + greeting = _greeting; + } +} diff --git a/packages/hardhat-solx/test/fixture-projects/simple/hardhat.config.ts b/packages/hardhat-solx/test/fixture-projects/simple/hardhat.config.ts new file mode 100644 index 00000000000..66e5324f1c0 --- /dev/null +++ b/packages/hardhat-solx/test/fixture-projects/simple/hardhat.config.ts @@ -0,0 +1,20 @@ +import type { HardhatUserConfig } from "hardhat/config"; + +import HardhatSolxPlugin from "../../../src/index.js"; + +const config: HardhatUserConfig = { + solidity: { + profiles: { + default: { + version: "0.8.33", + }, + solx: { + type: "solx", + version: "0.8.33", + }, + }, + }, + plugins: [HardhatSolxPlugin], +}; + +export default config; diff --git a/packages/hardhat-solx/test/fixture-projects/simple/package.json b/packages/hardhat-solx/test/fixture-projects/simple/package.json new file mode 100644 index 00000000000..2783004b132 --- /dev/null +++ b/packages/hardhat-solx/test/fixture-projects/simple/package.json @@ -0,0 +1,6 @@ +{ + "name": "hardhat-solx-simple-fixture", + "private": true, + "type": "module", + "version": "0.0.1" +} diff --git a/packages/hardhat-solx/test/hook-handlers/solidity.ts b/packages/hardhat-solx/test/hook-handlers/solidity.ts new file mode 100644 index 00000000000..dc1373deadb --- /dev/null +++ b/packages/hardhat-solx/test/hook-handlers/solidity.ts @@ -0,0 +1,319 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- test */ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- test */ +import type { SolidityCompilerConfig } from "hardhat/types/config"; +import type { CompilerInput, CompilerOutput } from "hardhat/types/solidity"; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; + +import { parseSolxVersion } from "../../src/internal/hook-handlers/solidity.js"; + +// Helper to create a compiler config +function createSolidityCompilerConfig( + overrides: Partial = {}, +): SolidityCompilerConfig { + return { + version: "0.8.33", + settings: { + optimizer: {}, + outputSelection: {}, + }, + ...overrides, + }; +} + +// A mock "next" function for getCompiler +function createGetCompilerMockNext() { + let called = false; + const mockCompiler = { + version: "0.8.33", + longVersion: "0.8.33+commit.abc123", + compilerPath: "/path/to/solc", + isSolcJs: false, + compile: async (_input: CompilerInput): Promise => ({ + sources: {}, + contracts: {}, + }), + }; + + const next = async ( + _context: any, + _compilerConfig: SolidityCompilerConfig, + ) => { + called = true; + return mockCompiler; + }; + + return { + next, + wasCalled: () => called, + compiler: mockCompiler, + }; +} + +describe("parseSolxVersion", () => { + it("parses version from stable release output", () => { + assert.equal( + parseSolxVersion( + "solx, LLVM-based Solidity compiler for the EVM v0.1.3, LLVM revision: v1.0.2, LLVM build: a33d492", + ), + "0.1.3", + ); + }); + + it("parses version from nightly build output", () => { + assert.equal( + parseSolxVersion( + "solx v0.1.4, LLVM-based Solidity compiler for the EVM, Front end: solc, LLVM build: 12f24e07", + ), + "0.1.4", + ); + }); + + it("parses pre-release version", () => { + assert.equal( + parseSolxVersion("solx v0.2.0-alpha.1, LLVM-based Solidity compiler"), + "0.2.0-alpha.1", + ); + }); +}); + +describe("hardhat-solx solidity hook handler", () => { + describe("downloadCompilers", () => { + it("is defined on the hook handler", async () => { + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + assert.ok( + hooks.downloadCompilers !== undefined, + "downloadCompilers hook should be defined", + ); + }); + + it("does nothing when no solx-typed compilers present", async () => { + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + + // All configs are solc (no type or type undefined) + const configs: SolidityCompilerConfig[] = [ + createSolidityCompilerConfig({ type: undefined }), + createSolidityCompilerConfig({ type: "solc" }), + ]; + + // Should not throw + assert.ok( + hooks.downloadCompilers !== undefined, + "downloadCompilers hook should be defined", + ); + await hooks.downloadCompilers(context, configs, true); + + // Paths should remain undefined (no download triggered, no mutation) + assert.equal(configs[0].path, undefined); + assert.equal(configs[1].path, undefined); + }); + + it("does not mutate compiler config paths", async () => { + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + + const configs: SolidityCompilerConfig[] = [ + createSolidityCompilerConfig({ type: "solx", version: "0.8.33" }), + ]; + + // This will fail to download (no network in tests), but we can + // verify via the error that it tries and that path is not mutated. + // For a true unit test we'd mock downloadSolx, but for now just + // check the path isn't set before the download attempt. + const originalPath = configs[0].path; + + try { + await hooks.downloadCompilers!(context, configs, true); + } catch { + // Expected — download fails in test environment + } + + assert.equal( + configs[0].path, + originalPath, + "compiler config path should not be mutated", + ); + }); + }); + + describe("getCompiler", () => { + it("is defined on the hook handler", async () => { + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + assert.ok( + hooks.getCompiler !== undefined, + "getCompiler hook should be defined", + ); + }); + + it("passes through to next for non-solx compiler configs", async () => { + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + const compilerConfig = createSolidityCompilerConfig({ type: "solc" }); + const mockNext = createGetCompilerMockNext(); + + const result = await hooks.getCompiler!( + context, + compilerConfig, + mockNext.next, + ); + + assert.ok(mockNext.wasCalled(), "next should have been called"); + assert.equal(result, mockNext.compiler); + }); + + it("passes through to next for undefined type", async () => { + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + const compilerConfig = createSolidityCompilerConfig({ type: undefined }); + const mockNext = createGetCompilerMockNext(); + + const result = await hooks.getCompiler!( + context, + compilerConfig, + mockNext.next, + ); + + assert.ok(mockNext.wasCalled(), "next should have been called"); + assert.equal(result, mockNext.compiler); + }); + + it("throws invariant error for unsupported solx version", async () => { + const { HardhatError } = await import("@nomicfoundation/hardhat-errors"); + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + const compilerConfig = createSolidityCompilerConfig({ + type: "solx", + version: "0.8.99", + }); + const mockNext = createGetCompilerMockNext(); + + await assertRejectsWithHardhatError( + hooks.getCompiler!(context, compilerConfig, mockNext.next), + HardhatError.ERRORS.CORE.INTERNAL.ASSERTION_ERROR, + { + message: + "No solx version mapping for Solidity 0.8.99 — this should have been caught by config validation", + }, + ); + }); + + it("throws HardhatError when path does not exist", async () => { + const { HardhatError } = await import("@nomicfoundation/hardhat-errors"); + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + const compilerConfig = createSolidityCompilerConfig({ + type: "solx", + version: "0.8.33", + path: "/nonexistent/path/to/solx", + }); + const mockNext = createGetCompilerMockNext(); + + await assertRejectsWithHardhatError( + hooks.getCompiler!(context, compilerConfig, mockNext.next), + HardhatError.ERRORS.HARDHAT_SOLX.GENERAL.BINARY_NOT_FOUND, + { + path: "/nonexistent/path/to/solx", + }, + ); + }); + + it("returns SolxCompiler with version from binary when path is provided", async () => { + const { getSolxBinaryPath } = await import( + "../../src/internal/downloader.js" + ); + const { exists } = await import("@nomicfoundation/hardhat-utils/fs"); + + // Use the cached solx binary if available, skip otherwise + const cachedPath = await getSolxBinaryPath("0.1.3"); + if (!(await exists(cachedPath))) { + return; + } + + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + const compilerConfig = createSolidityCompilerConfig({ + type: "solx", + version: "0.8.33", + path: cachedPath, + }); + const mockNext = createGetCompilerMockNext(); + + const compiler = await hooks.getCompiler!( + context, + compilerConfig, + mockNext.next, + ); + + assert.ok( + !mockNext.wasCalled(), + "next should NOT have been called for solx type", + ); + assert.equal(compiler.compilerPath, cachedPath); + // Version should be parsed from the binary, not from config + assert.equal(compiler.version, "0.1.3"); + assert.equal(compiler.longVersion, "0.1.3+solx"); + }); + }); + + describe("downloadCompilers with path override", () => { + it("skips download when config has custom path", async () => { + const hookHandlerModule = await import( + "../../src/internal/hook-handlers/solidity.js" + ); + const hooks = await hookHandlerModule.default(); + + const context = { config: {} } as any; + + const configs: SolidityCompilerConfig[] = [ + createSolidityCompilerConfig({ + type: "solx", + version: "0.8.33", + path: "/custom/path/to/solx", + }), + ]; + + // Should not throw — download is skipped for configs with path + await hooks.downloadCompilers!(context, configs, true); + }); + }); +}); diff --git a/packages/hardhat-solx/test/integration.ts b/packages/hardhat-solx/test/integration.ts new file mode 100644 index 00000000000..caee22df3fd --- /dev/null +++ b/packages/hardhat-solx/test/integration.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { useFixtureProject } from "@nomicfoundation/hardhat-test-utils"; +import { + createHardhatRuntimeEnvironment, + importUserConfig, + resolveHardhatConfigPath, +} from "hardhat/hre"; + +describe("hardhat-solx integration", () => { + useFixtureProject("simple"); + + async function createHre() { + const configPath = await resolveHardhatConfigPath(); + const userConfig = await importUserConfig(configPath); + return createHardhatRuntimeEnvironment(userConfig); + } + + it("resolves plugin config through the HRE", async () => { + const hre = await createHre(); + assert.equal(hre.config.solx.dangerouslyAllowSolxInProduction, false); + }); + + it("resolves plugin config with defaults when not specified", async () => { + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + profiles: { + default: { + version: "0.8.33", + }, + solx: { + type: "solx", + version: "0.8.33", + }, + }, + }, + plugins: [(await import("../src/index.js")).default], + }); + + assert.equal(hre.config.solx.dangerouslyAllowSolxInProduction, false); + }); + + it("default profile compilers use solc (no type or 'solc')", async () => { + const hre = await createHre(); + + const defaultProfile = hre.config.solidity.profiles.default; + assert.ok(defaultProfile !== undefined, "default profile should exist"); + assert.ok( + defaultProfile.compilers.length > 0, + "should have at least one compiler", + ); + const compilerType = defaultProfile.compilers[0].type; + assert.ok( + compilerType === undefined || compilerType === "solc", + `default profile compiler type should be solc, got: ${compilerType}`, + ); + }); + + it("includes 'solx' build profile in resolved config", async () => { + const hre = await createHre(); + + const profileNames = Object.keys(hre.config.solidity.profiles); + assert.ok( + profileNames.includes("solx"), + `Expected "solx" profile in: ${profileNames.join(", ")}`, + ); + + const solxProfile = hre.config.solidity.profiles.solx; + assert.equal( + solxProfile.compilers[0].type, + "solx", + "solx profile compiler should have type: 'solx'", + ); + }); + + it("registers 'solx' as a compiler type", async () => { + const hre = await createHre(); + + assert.ok( + hre.config.solidity.registeredCompilerTypes.includes("solx"), + "registeredCompilerTypes should include 'solx'", + ); + }); +}); diff --git a/packages/hardhat-solx/test/platform.ts b/packages/hardhat-solx/test/platform.ts new file mode 100644 index 00000000000..cc3577020cd --- /dev/null +++ b/packages/hardhat-solx/test/platform.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + getSolxAssetName, + getSolxBinaryBaseName, +} from "../src/internal/platform.js"; + +describe("hardhat-solx platform detection", () => { + it("returns a valid base name for the current platform", () => { + const baseName = getSolxBinaryBaseName(); + assert.ok(typeof baseName === "string", "base name should be a string"); + assert.ok( + baseName.startsWith("solx-"), + "base name should start with 'solx-'", + ); + assert.ok(baseName.length > 5, "base name should have meaningful length"); + }); + + it("base name matches expected platform format", () => { + const baseName = getSolxBinaryBaseName(); + const platform = process.platform; + const arch = process.arch; + + if (platform === "linux" && arch === "x64") { + assert.equal(baseName, "solx-linux-amd64-gnu"); + } else if (platform === "linux" && arch === "arm64") { + assert.equal(baseName, "solx-linux-arm64-gnu"); + } else if (platform === "darwin") { + assert.equal(baseName, "solx-macosx"); + } else if (platform === "win32" && arch === "x64") { + assert.equal(baseName, "solx-windows-amd64-gnu"); + } + }); + + it("asset name includes version suffix", () => { + const assetName = getSolxAssetName("0.1.3"); + assert.ok( + assetName.includes("-v0.1.3"), + `asset name should include version suffix: ${assetName}`, + ); + assert.ok( + assetName.startsWith("solx-"), + `asset name should start with 'solx-': ${assetName}`, + ); + }); +}); diff --git a/packages/hardhat-solx/test/solx-compiler.ts b/packages/hardhat-solx/test/solx-compiler.ts new file mode 100644 index 00000000000..8b83aaf6662 --- /dev/null +++ b/packages/hardhat-solx/test/solx-compiler.ts @@ -0,0 +1,125 @@ +import type { CompilerInput, CompilerOutput } from "hardhat/types/solidity"; + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { SolxCompiler } from "../src/internal/solx-compiler.js"; + +// Track calls to the fake spawnCompile +let spawnCompileCalls: Array<{ + command: string; + args: string[]; + input: CompilerInput; +}> = []; +const fakeOutput: CompilerOutput = { sources: {}, contracts: {} }; + +async function fakeSpawnCompile( + command: string, + args: string[], + input: CompilerInput, +): Promise { + spawnCompileCalls.push({ command, args, input }); + return fakeOutput; +} + +describe("SolxCompiler", () => { + beforeEach(() => { + spawnCompileCalls = []; + }); + + it("implements the Compiler interface", async () => { + const compiler = new SolxCompiler("0.1.3", "/path/to/solx"); + + assert.equal(compiler.version, "0.1.3"); + assert.equal(compiler.longVersion, "0.1.3+solx"); + assert.equal(compiler.compilerPath, "/path/to/solx"); + assert.equal(compiler.isSolcJs, false); + }); + + it("calls spawnCompile with correct binary path and args", async () => { + const compiler = new SolxCompiler( + "0.1.3", + "/path/to/solx", + {}, + fakeSpawnCompile, + ); + const input: CompilerInput = { + language: "Solidity", + sources: { "A.sol": { content: "pragma solidity ^0.8.0;" } }, + settings: { optimizer: { enabled: true }, outputSelection: {} }, + }; + + await compiler.compile(input); + + assert.equal(spawnCompileCalls.length, 1); + const call = spawnCompileCalls[0]; + assert.equal(call.command, "/path/to/solx"); + assert.deepEqual(call.args, ["--standard-json", "--no-import-callback"]); + }); + + it("merges extraSettings into input.settings", async () => { + const compiler = new SolxCompiler( + "0.1.3", + "/path/to/solx", + { LLVMOptimization: "1" }, + fakeSpawnCompile, + ); + const input: CompilerInput = { + language: "Solidity", + sources: { "A.sol": { content: "pragma solidity ^0.8.0;" } }, + settings: { + optimizer: { enabled: true }, + outputSelection: { "*": { "*": ["abi"] } }, + }, + }; + + await compiler.compile(input); + + assert.equal(spawnCompileCalls.length, 1); + assert.deepEqual(spawnCompileCalls[0].input.settings, { + optimizer: { enabled: true }, + outputSelection: { "*": { "*": ["abi"] } }, + LLVMOptimization: "1", + }); + }); + + it("does not modify the original input object", async () => { + const compiler = new SolxCompiler( + "0.1.3", + "/path/to/solx", + { LLVMOptimization: "1" }, + fakeSpawnCompile, + ); + const input: CompilerInput = { + language: "Solidity", + sources: { "A.sol": { content: "pragma solidity ^0.8.0;" } }, + settings: { optimizer: { enabled: true }, outputSelection: {} }, + }; + + const originalSettings = { ...input.settings }; + await compiler.compile(input); + + assert.deepEqual( + input.settings, + originalSettings, + "Original input settings should not be modified", + ); + }); + + it("returns the output from spawnCompile", async () => { + const compiler = new SolxCompiler( + "0.1.3", + "/path/to/solx", + {}, + fakeSpawnCompile, + ); + const input: CompilerInput = { + language: "Solidity", + sources: { "A.sol": { content: "pragma solidity ^0.8.0;" } }, + settings: { optimizer: {}, outputSelection: {} }, + }; + + const result = await compiler.compile(input); + assert.equal(result, fakeOutput); + }); +}); diff --git a/packages/hardhat-solx/tsconfig.json b/packages/hardhat-solx/tsconfig.json new file mode 100644 index 00000000000..4796cdc5061 --- /dev/null +++ b/packages/hardhat-solx/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../config/tsconfig.base.json", + "references": [ + { + "path": "../hardhat" + }, + { + "path": "../hardhat-errors" + }, + { + "path": "../hardhat-node-test-reporter" + }, + { + "path": "../hardhat-test-utils" + }, + { + "path": "../hardhat-utils" + }, + { + "path": "../hardhat-zod-utils" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33a84cc5280..f030e852e48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1037,6 +1037,58 @@ importers: specifier: ~5.8.0 version: 5.8.3 + packages/hardhat-solx: + dependencies: + '@nomicfoundation/hardhat-errors': + specifier: workspace:^3.0.3 + version: link:../hardhat-errors + '@nomicfoundation/hardhat-utils': + specifier: workspace:^3.0.5 + version: link:../hardhat-utils + '@nomicfoundation/hardhat-zod-utils': + specifier: workspace:^3.0.0 + version: link:../hardhat-zod-utils + debug: + specifier: ^4.3.2 + version: 4.4.3(supports-color@5.5.0) + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@nomicfoundation/hardhat-node-test-reporter': + specifier: workspace:^3.0.0 + version: link:../hardhat-node-test-reporter + '@nomicfoundation/hardhat-test-utils': + specifier: workspace:^ + version: link:../hardhat-test-utils + '@types/debug': + specifier: ^4.1.7 + version: 4.1.12 + '@types/node': + specifier: ^22.0.0 + version: 22.18.7 + c8: + specifier: ^9.1.0 + version: 9.1.0 + eslint: + specifier: 9.25.1 + version: 9.25.1 + hardhat: + specifier: workspace:^3.1.6 + version: link:../hardhat + prettier: + specifier: 3.2.5 + version: 3.2.5 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + tsx: + specifier: ^4.19.3 + version: 4.20.6 + typescript: + specifier: ~5.8.0 + version: 5.8.3 + packages/hardhat-test-utils: dependencies: '@nomicfoundation/hardhat-errors': diff --git a/scripts/lib/packages.ts b/scripts/lib/packages.ts index bb3655750b1..1e2bd646e5a 100644 --- a/scripts/lib/packages.ts +++ b/scripts/lib/packages.ts @@ -13,6 +13,7 @@ export async function readAllReleasablePackages() { "example-project", "template-package", "hardhat-test-utils", + "hardhat-solx", ].includes(file), );