From 80f6763e8da44bb3d082c829fa3e386bd7d7914f Mon Sep 17 00:00:00 2001 From: Marian Fechete Date: Thu, 1 Oct 2026 18:06:22 +0200 Subject: [PATCH 1/9] Add hardhat-slang plugin scaffold copied from hardhat-slang-solx --- packages/hardhat-errors/src/descriptors.ts | 69 ++ packages/hardhat-slang/.gitignore | 14 + packages/hardhat-slang/.prettierignore | 6 + packages/hardhat-slang/LICENSE | 9 + packages/hardhat-slang/README.md | 160 ++++ packages/hardhat-slang/eslint.config.js | 3 + packages/hardhat-slang/package.json | 68 ++ packages/hardhat-slang/src/index.ts | 16 + .../hardhat-slang/src/internal/constants.ts | 42 + .../hardhat-slang/src/internal/downloader.ts | 283 ++++++ .../src/internal/hook-handlers/config.ts | 338 +++++++ .../src/internal/hook-handlers/solidity.ts | 131 +++ .../hardhat-slang/src/internal/platform.ts | 40 + .../src/internal/slang-compiler.ts | 69 ++ packages/hardhat-slang/src/type-extensions.ts | 64 ++ packages/hardhat-slang/test/config.ts | 889 ++++++++++++++++++ packages/hardhat-slang/test/downloader.ts | 274 ++++++ .../simple/contracts/Greeter.sol | 18 + .../fixture-projects/simple/hardhat.config.ts | 20 + .../test/fixture-projects/simple/package.json | 6 + .../with-debug-info/.gitignore | 2 + .../contracts/ConstructorRevert.sol | 14 + .../with-debug-info/contracts/Counter.sol | 15 + .../with-debug-info/contracts/InlineAsm.sol | 15 + .../with-debug-info/hardhat.config.ts | 20 + .../with-debug-info/package.json | 6 + .../test/hook-handlers/solidity.ts | 308 ++++++ packages/hardhat-slang/test/integration.ts | 86 ++ .../hardhat-slang/test/mirror-availability.ts | 68 ++ packages/hardhat-slang/test/optimizer-mode.ts | 54 ++ .../hardhat-slang/test/output-augmentation.ts | 146 +++ packages/hardhat-slang/test/platform.ts | 47 + packages/hardhat-slang/test/slang-compiler.ts | 124 +++ packages/hardhat-slang/tsconfig.json | 23 + pnpm-lock.yaml | 46 + 35 files changed, 3493 insertions(+) create mode 100644 packages/hardhat-slang/.gitignore create mode 100644 packages/hardhat-slang/.prettierignore create mode 100644 packages/hardhat-slang/LICENSE create mode 100644 packages/hardhat-slang/README.md create mode 100644 packages/hardhat-slang/eslint.config.js create mode 100644 packages/hardhat-slang/package.json create mode 100644 packages/hardhat-slang/src/index.ts create mode 100644 packages/hardhat-slang/src/internal/constants.ts create mode 100644 packages/hardhat-slang/src/internal/downloader.ts create mode 100644 packages/hardhat-slang/src/internal/hook-handlers/config.ts create mode 100644 packages/hardhat-slang/src/internal/hook-handlers/solidity.ts create mode 100644 packages/hardhat-slang/src/internal/platform.ts create mode 100644 packages/hardhat-slang/src/internal/slang-compiler.ts create mode 100644 packages/hardhat-slang/src/type-extensions.ts create mode 100644 packages/hardhat-slang/test/config.ts create mode 100644 packages/hardhat-slang/test/downloader.ts create mode 100644 packages/hardhat-slang/test/fixture-projects/simple/contracts/Greeter.sol create mode 100644 packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts create mode 100644 packages/hardhat-slang/test/fixture-projects/simple/package.json create mode 100644 packages/hardhat-slang/test/fixture-projects/with-debug-info/.gitignore create mode 100644 packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/ConstructorRevert.sol create mode 100644 packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/Counter.sol create mode 100644 packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/InlineAsm.sol create mode 100644 packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts create mode 100644 packages/hardhat-slang/test/fixture-projects/with-debug-info/package.json create mode 100644 packages/hardhat-slang/test/hook-handlers/solidity.ts create mode 100644 packages/hardhat-slang/test/integration.ts create mode 100644 packages/hardhat-slang/test/mirror-availability.ts create mode 100644 packages/hardhat-slang/test/optimizer-mode.ts create mode 100644 packages/hardhat-slang/test/output-augmentation.ts create mode 100644 packages/hardhat-slang/test/platform.ts create mode 100644 packages/hardhat-slang/test/slang-compiler.ts create mode 100644 packages/hardhat-slang/tsconfig.json diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index c91951187ee..e36b37acd31 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -359,6 +359,19 @@ export const ERROR_CATEGORIES: { }, }, }, + HARDHAT_SLANG: { + min: 130000, + max: 139999, + pluginId: "hardhat-slang", + websiteTitle: "Hardhat Slang", + CATEGORIES: { + GENERAL: { + min: 130000, + max: 130099, + websiteSubTitle: "General errors", + }, + }, + }, }; export const ERRORS = { @@ -3492,4 +3505,60 @@ Remove --grep-exclude, or run the tests that need it through the Mocha or Solidi }, }, }, + HARDHAT_SLANG: { + GENERAL: { + UNSUPPORTED_PLATFORM: { + number: 130000, + messageTemplate: `slang is not available for the current platform ({platform}/{arch}). + +slang supports: linux/x64, linux/arm64, darwin (macOS), windows/x64.`, + websiteTitle: "Unsupported platform", + websiteDescription: `The slang compiler is not available for your current operating system and architecture combination. + +slang supports: linux/x64, linux/arm64, darwin (macOS), windows/x64.`, + }, + DOWNLOAD_FAILED: { + number: 130001, + messageTemplate: `Failed to download slang {version} after {attempts} attempts: {reason}`, + websiteTitle: "slang download failed", + websiteDescription: `The slang compiler binary could not be downloaded from the slang releases mirror used by Hardhat. + +Check your internet connection, ensure that the slang releases mirror is reachable from your environment, and verify that the requested slang version exists. + +Hardhat sends its requests through the proxy set in \`HTTPS_PROXY\` or \`HTTP_PROXY\`. If you are behind one, ensure that it can reach that mirror, or add it to \`NO_PROXY\` to connect directly.`, + }, + BINARY_NOT_FOUND: { + number: 130002, + messageTemplate: `slang binary not found at {path}`, + websiteTitle: "slang binary not found", + websiteDescription: `The configured custom path for the slang binary does not exist. + +Verify that the path in your Hardhat config points to a valid slang binary.`, + }, + CHECKSUM_DOWNLOAD_FAILED: { + number: 130003, + messageTemplate: `Couldn't download the checksum for slang {version} from {url}: {reason}`, + websiteTitle: "Couldn't obtain the slang checksum", + websiteDescription: `Every slang binary is published alongside a \`.sha256\` checksum file, which Hardhat uses to verify the download. Hardhat couldn't obtain that checksum, so it refused to use the binary. + +Check your internet connection, and ensure that the slang releases mirror is reachable from your environment. + +Hardhat sends its requests through the proxy set in \`HTTPS_PROXY\` or \`HTTP_PROXY\`. If you are behind one, ensure that it can reach that mirror, or add it to \`NO_PROXY\` to connect directly.`, + }, + INVALID_DOWNLOAD: { + number: 130004, + messageTemplate: `Couldn't download slang {version}: Checksum verification failed. + +Please check your internet connection and try again. + +If this error persists, run "npx hardhat clean --global".`, + websiteTitle: "Downloaded slang checksum verification failed", + websiteDescription: `Hardhat downloaded a slang binary, and its checksum didn't match the one published alongside it. The binary was deleted instead of being used. + +The likeliest cause is a corrupted or incomplete download. + +Please check your internet connection and try again. If this error persists, run \`npx hardhat clean --global\`.`, + }, + }, + }, } as const; diff --git a/packages/hardhat-slang/.gitignore b/packages/hardhat-slang/.gitignore new file mode 100644 index 00000000000..0e1706cfcd7 --- /dev/null +++ b/packages/hardhat-slang/.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-slang/.prettierignore b/packages/hardhat-slang/.prettierignore new file mode 100644 index 00000000000..f6ebd3c5cd2 --- /dev/null +++ b/packages/hardhat-slang/.prettierignore @@ -0,0 +1,6 @@ +/node_modules +/dist +/coverage +CHANGELOG.md +/test/fixture-projects/**/artifacts +/test/fixture-projects/**/cache diff --git a/packages/hardhat-slang/LICENSE b/packages/hardhat-slang/LICENSE new file mode 100644 index 00000000000..a9f4e30f797 --- /dev/null +++ b/packages/hardhat-slang/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-slang/README.md b/packages/hardhat-slang/README.md new file mode 100644 index 00000000000..f1bd7bc1acc --- /dev/null +++ b/packages/hardhat-slang/README.md @@ -0,0 +1,160 @@ +# Hardhat Slang Solx plugin + +This plugin enables the [slang-solx](https://github.com/NomicFoundation/solx) Solidity compiler in Hardhat. `slang-solx` is a Solidity compiler based on solc and LLVM. It compiles faster, produces better optimized bytecode, and gets rid of β€œstack too deep” errors. + +The `slang-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 `slang-solx` in other build profiles, see configuration flags below. + +The compiler was originally developed at Matter Labs under the name `solx`. It moved to the Nomic Foundation in 2025, where it was renamed `slang-solx`. Its repository and release assets still use the `solx` name. + +> πŸ’‘ **Tip:** Read the [Compiling with slang-solx](https://hardhat.org/docs/cookbook/compiling-with-slang-solx) guide for more details. + +## Installation + +```bash +npm install --save-dev @nomicfoundation/hardhat-slang-solx +``` + +Then add the plugin to your `hardhat.config.ts` and create a `slang-solx` build profile. You must use the build profiles config format, which requires both a `default` and a `slang-solx` profile: + +```typescript +import { defineConfig } from "hardhat/config"; +import hardhatSlangSolx from "@nomicfoundation/hardhat-slang-solx"; + +export default defineConfig({ + plugins: [hardhatSlangSolx], + solidity: { + profiles: { + default: { + version: "0.8.29", + }, + "slang-solx": { + type: "slang-solx", + version: "0.8.34", + }, + }, + }, +}); +``` + +The `default` profile uses solc as usual. The `slang-solx` profile uses the slang-solx compiler, identified by `type: "slang-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 the hardhat-slang-solx plugin. See more details below for the currently supported Solidity versions and EVM versions. + +## Usage + +Run tests or compile using the slang-solx build profile: + +```bash +hardhat test --build-profile slang-solx +hardhat build --build-profile slang-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 `slang-solx` profile with multiple compilers. Compilers without `type: "slang-solx"` will use solc: + +```typescript +export default defineConfig({ + plugins: [hardhatSlangSolx], + solidity: { + profiles: { + default: { + compilers: [{ version: "0.8.34" }, { version: "0.8.20" }], + }, + "slang-solx": { + compilers: [ + { type: "slang-solx", version: "0.8.34" }, + { version: "0.8.20" }, // uses solc, slang-solx doesn't support this version + ], + }, + }, + }, +}); +``` + +### Options + +- `dangerouslyAllowSlangSolxInProduction` (`boolean`, default: `false`), allows compiler type `"slang-solx"` in build profiles other than `slang-solx`. By default, using `type: "slang-solx"` in any other profile (e.g. `default`, `production`) will produce a validation error. + +```typescript +export default defineConfig({ + plugins: [hardhatSlangSolx], + solidity: { + profiles: { + default: { + type: "slang-solx", // allowed only because of the option below + version: "0.8.34", + }, + "slang-solx": { + type: "slang-solx", + version: "0.8.34", + }, + }, + }, + "slang-solx": { + dangerouslyAllowSlangSolxInProduction: true, + }, +}); +``` + +### Optimization level + +slang-solx optimizes via LLVM; set the level per profile with `settings.optimizer.mode`: + +| Mode | What it does | +| ----- | ----------------------------------------------------- | +| `"1"` | Least optimization, fastest to compile (the default). | +| `"2"` | More runtime optimization. | +| `"3"` | Best runtime performance. | +| `"s"` | Smaller bytecode. | +| `"z"` | Smallest bytecode. | + +slang-solx has two independent optimizer knobs: + +- `optimizer.mode` (above) is the LLVM backend level. There is **no "off"** β€” the minimum is `"1"`, so LLVM always optimizes. +- `optimizer.enabled` is the embedded solc front end's own optimizer, `false` by default. It only affects the legacy pipeline, where it optimizes the EVM assembly slang-solx translates. Under `viaIR: true` it changes nothing but the metadata hash: slang-solx bypasses the Yul optimizer entirely, so the IR handed to LLVM is always unoptimized. + +For example, optimizing for size instead of performance: + +```typescript +import { defineConfig } from "hardhat/config"; +import hardhatSlangSolx from "@nomicfoundation/hardhat-slang-solx"; + +export default defineConfig({ + plugins: [hardhatSlangSolx], + solidity: { + profiles: { + default: { version: "0.8.34" }, + "slang-solx": { + type: "slang-solx", + version: "0.8.34", + settings: { optimizer: { mode: "z" } }, // optimize for size + }, + }, + }, +}); +``` + +Or, on the legacy pipeline, run the front end's assembly optimizer before LLVM `-O3` (both knobs on) β€” just the `slang-solx` profile: + +```typescript +"slang-solx": { + type: "slang-solx", + version: "0.8.34", + settings: { optimizer: { enabled: true, mode: "3" } }, +}, +``` + +### Supported Solidity versions + +slang-solx maps each Solidity version to a specific slang-solx binary version internally. Currently supported: `0.8.34` (solx 0.1.8). Earlier slang-solx releases did not emit the DWARF debug info that EDR relies on for Solidity stack traces, so they are not supported by this plugin. + +### EVM version support + +slang-solx supports EVM versions `cancun`, `prague`, and `osaka`. Using an older EVM target (e.g., `paris`, `shanghai`) with compiler type `"slang-solx"` will result in a validation error. diff --git a/packages/hardhat-slang/eslint.config.js b/packages/hardhat-slang/eslint.config.js new file mode 100644 index 00000000000..6372cd70d65 --- /dev/null +++ b/packages/hardhat-slang/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-slang/package.json b/packages/hardhat-slang/package.json new file mode 100644 index 00000000000..1a976392cd4 --- /dev/null +++ b/packages/hardhat-slang/package.json @@ -0,0 +1,68 @@ +{ + "name": "@nomicfoundation/hardhat-slang", + "version": "3.0.0", + "description": "Hardhat plugin for using the slang compiler in test builds", + "homepage": "https://github.com/NomicFoundation/hardhat/tree/main/packages/hardhat-slang", + "repository": { + "type": "git", + "url": "https://github.com/NomicFoundation/hardhat", + "directory": "packages/hardhat-slang" + }, + "author": "Nomic Foundation", + "license": "MIT", + "type": "module", + "types": "dist/src/index.d.ts", + "exports": { + ".": "./dist/src/index.js", + "./package.json": "./package.json" + }, + "keywords": [ + "ethereum", + "smart-contracts", + "hardhat", + "slang", + "compiler" + ], + "scripts": { + "lint": "pnpm eslint && pnpm prettier --check", + "lint:fix": "pnpm eslint --fix && pnpm prettier --write", + "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.1.0", + "@nomicfoundation/hardhat-test-utils": "workspace:^", + "@types/node": "^22.0.0", + "c8": "^12.0.0", + "eslint": "9.39.5", + "hardhat": "workspace:^3.15.0", + "prettier": "3.9.8", + "rimraf": "^6.1.3", + "tsx": "^4.23.1", + "typescript": "~6.0.3" + }, + "dependencies": { + "@nomicfoundation/hardhat-errors": "workspace:^3.1.0", + "@nomicfoundation/hardhat-utils": "workspace:^4.3.0", + "@nomicfoundation/hardhat-zod-utils": "workspace:^3.0.5", + "zod": "^3.23.8" + }, + "peerDependencies": { + "hardhat": "workspace:^3.15.0" + } +} diff --git a/packages/hardhat-slang/src/index.ts b/packages/hardhat-slang/src/index.ts new file mode 100644 index 00000000000..7f827586331 --- /dev/null +++ b/packages/hardhat-slang/src/index.ts @@ -0,0 +1,16 @@ +import type { HardhatPlugin } from "hardhat/types/plugins"; + +import { definePlugin } from "hardhat/plugins"; + +export type * from "./type-extensions.js"; + +const hardhatSlangPlugin: HardhatPlugin = definePlugin({ + id: "hardhat-slang", + npmPackage: "@nomicfoundation/hardhat-slang", + hookHandlers: { + config: () => import("./internal/hook-handlers/config.js"), + solidity: () => import("./internal/hook-handlers/solidity.js"), + }, +}); + +export default hardhatSlangPlugin; diff --git a/packages/hardhat-slang/src/internal/constants.ts b/packages/hardhat-slang/src/internal/constants.ts new file mode 100644 index 00000000000..e2e2b068b11 --- /dev/null +++ b/packages/hardhat-slang/src/internal/constants.ts @@ -0,0 +1,42 @@ +import type { SolidityCompilerType } from "hardhat/types/config"; + +/** + * The compiler type identifier registered by this plugin. + * Typed as SolidityCompilerType for type-safe comparisons. + */ +export const SLANG_COMPILER_TYPE: SolidityCompilerType = "slang"; + +// TODO: revisit before release to finalize. +export const SLANG_RELEASES_BASE_URL = + "https://solx-releases-mirror.hardhat.org"; + +export const SUPPORTED_SLANG_EVM_VERSIONS: readonly string[] = [ + "cancun", + "prague", + "osaka", +] as const; + +/** + * The LLVM optimization levels slang accepts in `settings.optimizer.mode`, + * lowercase as slang expects. There is no level that turns optimization + * off: "1" is the minimum. + */ +export const SUPPORTED_SLANG_OPTIMIZER_MODES: readonly string[] = [ + "1", + "2", + "3", + "s", + "z", +] as const; + +/** + * Default LLVM optimization level, passed to slang via `settings.optimizer.mode`. + * Deliberately -O1 to optimize for compile speed (slang's own default if + * unset is -O3). + */ +export const DEFAULT_SLANG_OPTIMIZER_MODE = "1"; + +/** Maps Solidity versions to the slang version that embeds them. */ +export const SOLIDITY_TO_SOLX_VERSION_MAP: Record = { + "0.8.34": "0.1.8", +}; diff --git a/packages/hardhat-slang/src/internal/downloader.ts b/packages/hardhat-slang/src/internal/downloader.ts new file mode 100644 index 00000000000..4c8938d99b1 --- /dev/null +++ b/packages/hardhat-slang/src/internal/downloader.ts @@ -0,0 +1,283 @@ +import type { PrefixedHexString } from "@nomicfoundation/hardhat-utils/hex"; +import type { Dispatcher } from "@nomicfoundation/hardhat-utils/request"; + +import path from "node:path"; + +import { + assertHardhatInvariant, + HardhatError, +} from "@nomicfoundation/hardhat-errors"; +import { sha256 } from "@nomicfoundation/hardhat-utils/crypto"; +import { createDebug } from "@nomicfoundation/hardhat-utils/debug"; +import { ensureError } from "@nomicfoundation/hardhat-utils/error"; +import { + chmod, + exists, + move, + readBinaryFile, + remove, +} from "@nomicfoundation/hardhat-utils/fs"; +import { getCacheDir } from "@nomicfoundation/hardhat-utils/global-dir"; +import { + bytesToHexString, + getPrefixedHexString, + getUnprefixedHexString, + isHexString, +} from "@nomicfoundation/hardhat-utils/hex"; +import { + download, + getRequest, + ResponseStatusCodeError, +} from "@nomicfoundation/hardhat-utils/request"; +import { MultiProcessMutex } from "@nomicfoundation/hardhat-utils/synchronization"; + +import { SLANG_RELEASES_BASE_URL } from "./constants.js"; +import { getSlangAssetName } from "./platform.js"; + +const log = createDebug("hardhat:slang:downloader"); + +const DOWNLOAD_RETRY_COUNT = 3; +const SHA256_HEX_DIGEST_LENGTH = 64; +const DOWNLOAD_RETRY_DELAY_MS = 2000; + +/** + * Returns the deterministic path where a slang 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 getSlangBinaryPath( + slangVersion: string, +): Promise { + const assetName = getSlangAssetName(slangVersion); + const globalCacheDir = await getCacheDir(); + return path.join( + globalCacheDir, + "compilers-v3", + `slang-v${slangVersion}`, + assetName, + ); +} + +export interface DownloadSlangOptions { + /** + * The dispatcher used for the checksum and binary requests. Intended for + * tests. + */ + dispatcher?: Dispatcher; + + /** + * How long to wait between download attempts. Intended for tests. + */ + retryDelayMs?: number; +} + +/** + * Downloads the slang binary for the given version if not already cached. + * Returns the path to the binary on disk. + * + * @param slangVersion - The slang version to download (e.g. "0.1.3") + * @param onBinaryDownloadStart - A callback invoked once the compiler download is about to start + * @param options - See {@link DownloadSlangOptions}. + */ +export async function downloadSlang( + slangVersion: string, + onBinaryDownloadStart: () => void, + options: DownloadSlangOptions = {}, +): Promise { + const { dispatcher, retryDelayMs = DOWNLOAD_RETRY_DELAY_MS } = options; + const binaryPath = await getSlangBinaryPath(slangVersion); + + // Return cached binary if it already exists + if (await exists(binaryPath)) { + log(`Using cached slang binary at ${binaryPath}`); + return binaryPath; + } + + const globalCacheDir = await getCacheDir(); + const mutex = new MultiProcessMutex( + path.join(globalCacheDir, `slang-download-${slangVersion}`), + ); + const assetName = getSlangAssetName(slangVersion); + const url = `${SLANG_RELEASES_BASE_URL}/${assetName}`; + + // The checksum is required, we fail immediately if we can't get it. + const expectedChecksum = await downloadExpectedChecksum( + slangVersion, + `${url}.sha256`, + dispatcher, + ); + + log(`Downloading slang ${slangVersion} from ${url}`); + + for (let attempt = 1; attempt <= DOWNLOAD_RETRY_COUNT; attempt++) { + try { + // Use a mutex per retry iteration so other processes can proceed + // between retries + return await mutex.use(async () => { + // Check if another process downloaded it while we waited for the mutex + if (await exists(binaryPath)) { + log( + `Using cached slang binary at ${binaryPath} (downloaded by another process)`, + ); + + return binaryPath; + } + + // Signal download start only on the first attempt + if (attempt === 1) { + onBinaryDownloadStart(); + } + + // Download to a temporary path, we move into place after verifying the checksum + const downloadPath = `${binaryPath}.tmp`; + + await download(url, downloadPath, {}, dispatcher); + + const checksumValid = await verifyChecksum( + downloadPath, + expectedChecksum, + ); + + if (!checksumValid) { + throw new HardhatError( + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.INVALID_DOWNLOAD, + { version: slangVersion }, + ); + } + + // Set executable permission on Unix + if (process.platform !== "win32") { + await chmod(downloadPath, 0o755); + } + + await move(downloadPath, binaryPath); + + log(`Successfully downloaded slang ${slangVersion}`); + return binaryPath; + }); + } catch (error) { + ensureError(error); + log( + `Download attempt ${attempt}/${DOWNLOAD_RETRY_COUNT} failed: ${error.message}`, + ); + + if (attempt === DOWNLOAD_RETRY_COUNT) { + if (HardhatError.isHardhatError(error)) { + throw error; + } + + throw new HardhatError( + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.DOWNLOAD_FAILED, + { + version: slangVersion, + attempts: DOWNLOAD_RETRY_COUNT.toString(), + reason: error.message, + }, + error, + ); + } + + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + assertHardhatInvariant( + false, + "the retry loop always returns or throws on its last attempt", + ); +} + +/** + * Compares a downloaded binary against its expected SHA-256 checksum. On a + * mismatch the file is deleted and false is returned, leaving the caller to + * raise the error. + * + * This runs against the path the binary was downloaded to, before it is + * published to the path callers read, so a mismatch is deleted without ever + * having been visible. + */ +async function verifyChecksum( + downloadPath: string, + expectedChecksum: PrefixedHexString, +): Promise { + const binaryContents = await readBinaryFile(downloadPath); + const actualChecksum = bytesToHexString(await sha256(binaryContents)); + + if (expectedChecksum !== actualChecksum) { + log( + `SHA-256 mismatch for ${downloadPath}: expected ${expectedChecksum}, got ${actualChecksum}`, + ); + + await remove(downloadPath); + + return false; + } + + log(`SHA-256 checksum verified for ${downloadPath}`); + return true; +} + +/** + * Downloads the expected SHA-256 checksum of a slang asset from its `.sha256` + * sidecar file on the mirror. + */ +async function downloadExpectedChecksum( + slangVersion: string, + checksumUrl: string, + dispatcher?: Dispatcher, +): Promise { + let body: string; + + try { + const response = await getRequest(checksumUrl, {}, dispatcher); + + body = (await response.body.text()).trim(); + } catch (error) { + ensureError(error); + + throw new HardhatError( + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, + { + version: slangVersion, + url: checksumUrl, + reason: describeChecksumRequestFailure(error), + }, + error, + ); + } + + // The sidecar file contains the hex-encoded SHA-256 digest, possibly with a + // filename suffix, the way sha256sum writes it. We only need the digest. + const expectedChecksum = body.split(/\s+/)[0].toLowerCase(); + + // This is a guard against a failed HTTP lookup returning an HTML error rather + // than the expected digest. + if ( + !isHexString(expectedChecksum) || + getUnprefixedHexString(expectedChecksum).length !== SHA256_HEX_DIGEST_LENGTH + ) { + throw new HardhatError( + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, + { + version: slangVersion, + url: checksumUrl, + reason: "the response didn't contain a SHA-256 digest", + }, + ); + } + + return getPrefixedHexString(expectedChecksum); +} + +function describeChecksumRequestFailure(error: Error): string { + if (error instanceof ResponseStatusCodeError) { + return `the mirror responded with status ${error.statusCode}`; + } + + const { cause } = error; + if (cause instanceof Error && cause.message !== "") { + return cause.message; + } + + return error.message; +} diff --git a/packages/hardhat-slang/src/internal/hook-handlers/config.ts b/packages/hardhat-slang/src/internal/hook-handlers/config.ts new file mode 100644 index 00000000000..09dea837563 --- /dev/null +++ b/packages/hardhat-slang/src/internal/hook-handlers/config.ts @@ -0,0 +1,338 @@ +import type { + ConfigurationVariableResolver, + HardhatConfig, + HardhatUserConfig, + SlangConfig, +} from "hardhat/types/config"; +import type { + ConfigHooks, + HardhatConfigValidationError, + HardhatUserConfigValidationError, +} from "hardhat/types/hooks"; + +import { createDebug } from "@nomicfoundation/hardhat-utils/debug"; +import { isObject } from "@nomicfoundation/hardhat-utils/lang"; +import { + conditionalUnionType, + validateUserConfigZodType, +} from "@nomicfoundation/hardhat-zod-utils"; +import { z } from "zod"; + +import { + DEFAULT_SLANG_OPTIMIZER_MODE, + SOLIDITY_TO_SOLX_VERSION_MAP, + SLANG_COMPILER_TYPE, + SUPPORTED_SLANG_EVM_VERSIONS, + SUPPORTED_SLANG_OPTIMIZER_MODES, +} from "../constants.js"; +import { addSlangDebugInfoSelectors } from "../slang-compiler.js"; + +const log = createDebug("hardhat:slang: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_SLANG_EVM_VERSIONS.includes(val), { + message: `Slang only supports EVM versions: ${SUPPORTED_SLANG_EVM_VERSIONS.join(", ")}`, + }); + +const supportedOptimizerModesType = z + .string() + .refine((val) => SUPPORTED_SLANG_OPTIMIZER_MODES.includes(val), { + message: `Slang only supports optimizer modes: ${SUPPORTED_SLANG_OPTIMIZER_MODES.join(", ")}`, + }); + +const slangSolidityCompilerUserConfigType = z + .object({ + version: z.string(), + settings: z + .object({ + evmVersion: supportedEvmVersionsType.optional(), + optimizer: z + .object({ + mode: supportedOptimizerModesType.optional(), + }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough() + .refine( + (data) => + (typeof data.path === "string" && data.path.length > 0) || + SUPPORTED_VERSIONS.includes(data.version), + { + message: `Slang only supports versions: ${SUPPORTED_VERSIONS.join(", ")}`, + path: ["version"], + }, + ); + +const solidityCompilerUserConfigType = conditionalUnionType( + [ + [ + (data) => + isObject(data) && "type" in data && data.type === SLANG_COMPILER_TYPE, + slangSolidityCompilerUserConfigType, + ], + [(_data) => true, z.any()], + ], + "Expected a valid compiler configuration", +); + +const singleVersionSolidityUserConfigType = conditionalUnionType( + [ + [ + (data) => + isObject(data) && "type" in data && data.type === SLANG_COMPILER_TYPE, + slangSolidityCompilerUserConfigType, + ], + [(_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 === SLANG_COMPILER_TYPE, + slangSolidityCompilerUserConfigType, + ], + [(_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 slangUserConfigType = z.object({ + solidity: solidityUserConfigType.optional(), + slang: z + .object({ + dangerouslyAllowSlangInProduction: z.boolean().optional(), + }) + .optional(), +}); + +export default async (): Promise> => ({ + validateUserConfig, + resolveUserConfig, + validateResolvedConfig, +}); + +export async function validateUserConfig( + userConfig: HardhatUserConfig, +): Promise { + return validateUserConfigZodType(userConfig, slangUserConfigType); +} + +export async function resolveUserConfig( + userConfig: HardhatUserConfig, + resolveConfigurationVariable: ConfigurationVariableResolver, + next: ( + nextUserConfig: HardhatUserConfig, + nextResolveConfigurationVariable: ConfigurationVariableResolver, + ) => Promise, +): Promise { + const resolvedConfig = await next(userConfig, resolveConfigurationVariable); + + // Add slang debugInfo selectors so the cached solcInput, build-info, + // and build-ID hash all include them. + const profiles = await augmentSlangOutputSelectionInProfiles( + resolvedConfig.solidity.profiles, + ); + + return { + ...resolvedConfig, + solidity: { + ...resolvedConfig.solidity, + profiles, + registeredCompilerTypes: + resolvedConfig.solidity.registeredCompilerTypes.includes( + SLANG_COMPILER_TYPE, + ) + ? resolvedConfig.solidity.registeredCompilerTypes + : [ + ...resolvedConfig.solidity.registeredCompilerTypes, + SLANG_COMPILER_TYPE, + ], + }, + slang: resolveSlangConfig(userConfig.slang), + }; +} + +/** + * For each compiler entry whose `type === "slang"`, augments + * `settings.outputSelection` with the slang debugInfo selectors. + * Non-slang entries pass through unchanged. + */ +async function augmentSlangOutputSelectionInProfiles( + profiles: HardhatConfig["solidity"]["profiles"], +): Promise { + const result: Record = {}; + for (const [profileName, profile] of Object.entries(profiles)) { + const augmentedCompilers = await Promise.all( + profile.compilers.map((compiler) => augmentIfSlang(compiler)), + ); + const augmentedOverrides: Record< + string, + (typeof profile.overrides)[string] + > = {}; + for (const [overrideKey, override] of Object.entries(profile.overrides)) { + augmentedOverrides[overrideKey] = await augmentIfSlang(override); + } + result[profileName] = { + ...profile, + compilers: augmentedCompilers, + overrides: augmentedOverrides, + }; + } + return result; +} + +// SolidityCompilerConfig.settings is typed `any` upstream (see hardhat's +// `CommonSolidityCompilerConfig`), so we use the same here β€” narrowing +// would require type assertions that the repo's eslint config forbids. +async function augmentIfSlang< + T extends { type?: string; settings?: Record }, +>(entry: T): Promise { + if (entry.type !== SLANG_COMPILER_TYPE) { + return entry; + } + const settings = isObject(entry.settings) ? entry.settings : {}; + const optimizer: Record = isObject(settings.optimizer) + ? settings.optimizer + : {}; + return { + ...entry, + settings: { + ...settings, + // Defaults added here instead of in SlangCompiler.compile so this reaches + // the solcInput and hence the build-id hash. + viaIR: settings.viaIR ?? false, + optimizer: { + ...optimizer, + mode: optimizer.mode ?? DEFAULT_SLANG_OPTIMIZER_MODE, + }, + outputSelection: await addSlangDebugInfoSelectors( + entry.settings?.outputSelection, + ), + }, + }; +} + +export async function validateResolvedConfig( + resolvedConfig: HardhatConfig, +): Promise { + const errors: HardhatConfigValidationError[] = []; + + // Check that the user defined a "slang" build profile + if (resolvedConfig.solidity.profiles.slang === undefined) { + errors.push({ + path: ["solidity"], + message: + 'The hardhat-slang plugin has been installed, but no "slang" build profile was found in the Solidity configuration. Please read the plugin documentation for information on how to create a "slang" build profile.', + }); + } + + // Check that type: "slang" is not used in non-slang profiles + if (resolvedConfig.slang.dangerouslyAllowSlangInProduction) { + log( + "Skipping non-slang profile validation: dangerouslyAllowSlangInProduction is true", + ); + return errors; + } + + for (const [profileName, profile] of Object.entries( + resolvedConfig.solidity.profiles, + )) { + if (profileName === "slang") { + continue; + } + + const slangInOtherProfileMessage = `Compiler type "slang" is only supported in the "slang" build profile. Remove type: "slang" from the "${profileName}" profile compilers, or set dangerouslyAllowSlangInProduction in the "slang" plugin config.`; + + for (const [i, compiler] of profile.compilers.entries()) { + if (compiler.type === SLANG_COMPILER_TYPE) { + errors.push({ + path: ["solidity", "profiles", profileName, "compilers", i, "type"], + message: slangInOtherProfileMessage, + }); + } + } + + for (const [key, override] of Object.entries(profile.overrides)) { + if (override.type === SLANG_COMPILER_TYPE) { + errors.push({ + path: ["solidity", "profiles", profileName, "overrides", key, "type"], + message: slangInOtherProfileMessage, + }); + } + } + } + + return errors; +} + +function resolveSlangConfig(userConfig?: { + dangerouslyAllowSlangInProduction?: boolean; +}): SlangConfig { + return { + dangerouslyAllowSlangInProduction: + userConfig?.dangerouslyAllowSlangInProduction ?? false, + }; +} diff --git a/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts b/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts new file mode 100644 index 00000000000..d36ca9ac6c5 --- /dev/null +++ b/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts @@ -0,0 +1,131 @@ +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 { createDebug } from "@nomicfoundation/hardhat-utils/debug"; +import { exists } from "@nomicfoundation/hardhat-utils/fs"; + +import { + SOLIDITY_TO_SOLX_VERSION_MAP, + SLANG_COMPILER_TYPE, +} from "../constants.js"; +import { downloadSlang, getSlangBinaryPath } from "../downloader.js"; +import { SlangCompiler } from "../slang-compiler.js"; + +const log = createDebug("hardhat:slang:hook-handlers:solidity"); + +const execFileAsync = promisify(execFile); + +// NOTE: This function is exported for testing purposes +export function parseSlangVersion(versionOutput: string): string { + const firstLine = versionOutput.split("\n")[0]; + const match = firstLine.match(/ v(\d+\.\d+\.\d+(?:-[\w.]+)?)/); + assertHardhatInvariant( + match !== null, + `Could not parse slang version from --version output: ${versionOutput}`, + ); + + return match[1]; +} + +async function getSlangVersionFromBinary(binaryPath: string): Promise { + const { stdout } = await execFileAsync(binaryPath, ["--version"]); + log(`--version output: ${stdout}`); + return parseSlangVersion(stdout); +} + +export default async (): Promise> => ({ + downloadCompilers: async (_context, compilerConfigs, quiet) => { + const slangConfigs = compilerConfigs.filter( + (c) => c.type === SLANG_COMPILER_TYPE, + ); + + if (slangConfigs.length === 0) { + return; + } + + // Collect unique slang versions to download (skip configs with custom path) + const slangVersions = new Set(); + for (const config of slangConfigs) { + if (config.path !== undefined) { + log( + `Skipping download for Solidity ${config.version}: custom path provided`, + ); + continue; + } + const slangVersion = SOLIDITY_TO_SOLX_VERSION_MAP[config.version]; + if (slangVersion !== undefined) { + slangVersions.add(slangVersion); + } + } + + await Promise.all( + [...slangVersions].map(async (slangVersion) => { + const binaryPath = await getSlangBinaryPath(slangVersion); + if (await exists(binaryPath)) { + log(`slang ${slangVersion} already cached at ${binaryPath}`); + return; + } + + const slangPath = await downloadSlang(slangVersion, () => { + if (quiet) { + return; + } + + console.log(`Downloading slang ${slangVersion}`); + }); + log(`Downloaded slang ${slangVersion} to ${slangPath}`); + }), + ); + }, + + getCompiler: async (context, compilerConfig, next) => { + if (compilerConfig.type !== SLANG_COMPILER_TYPE) { + return await 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_SLANG.GENERAL.BINARY_NOT_FOUND, + { path: compilerConfig.path }, + ); + } + + const customSlangVersion = await getSlangVersionFromBinary( + compilerConfig.path, + ); + + log( + `Creating SlangCompiler with custom path for Solidity ${compilerConfig.version} (slang ${customSlangVersion}) at ${compilerConfig.path}`, + ); + + return new SlangCompiler(customSlangVersion, compilerConfig.path); + } + + const slangVersion = SOLIDITY_TO_SOLX_VERSION_MAP[compilerConfig.version]; + assertHardhatInvariant( + slangVersion !== undefined, + `No slang version mapping for Solidity ${compilerConfig.version} β€” this should have been caught by config validation`, + ); + + const binaryPath = await getSlangBinaryPath(slangVersion); + + assertHardhatInvariant( + await exists(binaryPath), + `slang binary not found at ${binaryPath} β€” downloadCompilers should have been called first`, + ); + + log( + `Creating SlangCompiler for Solidity ${compilerConfig.version} (slang ${slangVersion}) at ${binaryPath}`, + ); + + return new SlangCompiler(slangVersion, binaryPath); + }, +}); diff --git a/packages/hardhat-slang/src/internal/platform.ts b/packages/hardhat-slang/src/internal/platform.ts new file mode 100644 index 00000000000..5e914fbfb69 --- /dev/null +++ b/packages/hardhat-slang/src/internal/platform.ts @@ -0,0 +1,40 @@ +import os from "node:os"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; + +/** + * Returns the platform-specific base name for the slang 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.4): + * solx-linux-amd64-gnu-v0.1.4 + * solx-linux-arm64-gnu-v0.1.4 + * solx-macosx-v0.1.4 (universal binary) + * solx-windows-amd64-gnu-v0.1.4.exe + */ +export function getSlangBinaryBaseName(): string { + // TODO: revisit before release to finalize. + 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_SLANG.GENERAL.UNSUPPORTED_PLATFORM, + { + platform, + arch, + }, + ); +} + +export function getSlangAssetName(version: string): string { + const baseName = getSlangBinaryBaseName(); + if (process.platform === "win32") { + return `${baseName}-v${version}.exe`; + } + return `${baseName}-v${version}`; +} diff --git a/packages/hardhat-slang/src/internal/slang-compiler.ts b/packages/hardhat-slang/src/internal/slang-compiler.ts new file mode 100644 index 00000000000..d6415b08ca7 --- /dev/null +++ b/packages/hardhat-slang/src/internal/slang-compiler.ts @@ -0,0 +1,69 @@ +import type { + Compiler, + CompilerInput, + CompilerOutput, +} from "hardhat/types/solidity"; + +import { deepClone } from "@nomicfoundation/hardhat-utils/lang"; +import { spawnCompile as defaultSpawnCompile } from "hardhat/internal/solidity"; + +// Selectors for enabling DWARF `debugInfo` output +export const SLANG_DEBUG_INFO_SELECTORS: readonly string[] = [ + "evm.bytecode.debugInfo", + "evm.deployedBytecode.debugInfo", +] as const; + +export class SlangCompiler implements Compiler { + public readonly version: string; + public readonly longVersion: string; + public readonly compilerPath: string; + public readonly isSolcJs: boolean = false; + + readonly #spawnCompile: typeof defaultSpawnCompile; + + constructor( + slangVersion: string, + compilerPath: string, + spawnCompile: typeof defaultSpawnCompile = defaultSpawnCompile, + ) { + this.version = slangVersion; + this.longVersion = `${slangVersion}+slang`; + this.compilerPath = compilerPath; + this.#spawnCompile = spawnCompile; + } + + public async compile(input: CompilerInput): Promise { + const args = ["--standard-json", "--no-import-callback"]; + + return await this.#spawnCompile(this.compilerPath, args, input); + } +} + +/** + * Returns a new outputSelection with the slang debugInfo selectors at + * `["*"]["*"]`. Existing user selectors are preserved; downstream + * `#dedupeAndSortOutputSelection` removes duplicates. + */ +export async function addSlangDebugInfoSelectors( + outputSelection: unknown, +): Promise["outputSelection"]> { + const seed: Record< + string, + Record + > = typeof outputSelection === "object" && outputSelection !== null + ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- known shape + (outputSelection as Record>) + : {}; + const cloned: Record> = await deepClone( + seed, + ); + + // Hardhat normalizes outputSelection to populate `["*"]["*"]` upstream, but + // unit tests construct the input directly with `{}`, so make sure the slot + // exists before we push. + cloned["*"] ??= {}; + cloned["*"]["*"] ??= []; + cloned["*"]["*"].push(...SLANG_DEBUG_INFO_SELECTORS); + + return cloned; +} diff --git a/packages/hardhat-slang/src/type-extensions.ts b/packages/hardhat-slang/src/type-extensions.ts new file mode 100644 index 00000000000..c84f0d9abe3 --- /dev/null +++ b/packages/hardhat-slang/src/type-extensions.ts @@ -0,0 +1,64 @@ +declare module "hardhat/types/solidity" { + /** + * The slang pipeline, like solx 0.1.4+, leaves `evm.{deployed,}Bytecode.sourceMap` + * empty and emits + * source-mapping information as a hex-encoded ELF/DWARF blob in + * `evm.{deployed,}Bytecode.debugInfo` instead. The plugin opts the user's + * `outputSelection` into producing this field on every slang compile, so + * declare it here to reflect that contract. + */ + interface CompilerOutputBytecode { + debugInfo?: string; + } +} + +declare module "hardhat/types/config" { + export interface SolidityCompilerTypeDefinitions { + slang: true; + } + + export interface SlangSolidityCompilerUserConfig extends CommonSolidityCompilerUserConfig { + type: "slang"; + } + + export interface SolidityCompilerUserConfigPerType { + slang: SlangSolidityCompilerUserConfig; + } + + export interface SlangSolidityCompilerConfig extends CommonSolidityCompilerConfig { + type: "slang"; + } + + export interface SolidityCompilerConfigPerType { + slang: SlangSolidityCompilerConfig; + } + + export interface SlangSingleVersionSolidityUserConfig + extends + SlangSolidityCompilerUserConfig, + CommonSingleVersionSolidityUserConfig {} + + export interface SingleVersionSolidityUserConfigPerType { + slang: SlangSingleVersionSolidityUserConfig; + } + + export interface SlangUserConfig { + /** + * Allow compiler type `"slang"` in the production build profile. + * By default, `"slang"` in production is rejected as a safeguard. + */ + dangerouslyAllowSlangInProduction?: boolean; + } + + export interface SlangConfig { + dangerouslyAllowSlangInProduction: boolean; + } + + export interface HardhatUserConfig { + slang?: SlangUserConfig; + } + + export interface HardhatConfig { + slang: SlangConfig; + } +} diff --git a/packages/hardhat-slang/test/config.ts b/packages/hardhat-slang/test/config.ts new file mode 100644 index 00000000000..9bd7edfafa1 --- /dev/null +++ b/packages/hardhat-slang/test/config.ts @@ -0,0 +1,889 @@ +/* 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"; +import { SLANG_DEBUG_INFO_SELECTORS } from "../src/internal/slang-compiler.js"; + +describe("hardhat-slang plugin config validation", () => { + it("accepts valid config with dangerouslyAllowSlangInProduction", async () => { + const errors = await validateUserConfig({ + slang: { + dangerouslyAllowSlangInProduction: true, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts empty plugin config", async () => { + const errors = await validateUserConfig({ + slang: {}, + }); + assert.deepEqual(errors, []); + }); + + it("accepts config without plugin config key", async () => { + const errors = await validateUserConfig({}); + assert.deepEqual(errors, []); + }); + + it("rejects invalid dangerouslyAllowSlangInProduction type", async () => { + const errors = await validateUserConfig({ + slang: { dangerouslyAllowSlangInProduction: "yes" as any }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + }); + + it("rejects non-boolean dangerouslyAllowSlangInProduction", async () => { + const errors = await validateUserConfig({ + slang: { + dangerouslyAllowSlangInProduction: 1 as any, + }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + }); +}); + +describe("hardhat-slang 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.34", settings: {} }], + overrides: {}, + }, + }), + ); + + assert.equal(resolvedConfig.slang.dangerouslyAllowSlangInProduction, false); + }); + + it("resolves dangerouslyAllowSlangInProduction from user config", async () => { + const resolvedConfig = await resolveUserConfig( + { slang: { dangerouslyAllowSlangInProduction: true } }, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", settings: {} }], + overrides: {}, + }, + }), + ); + + assert.equal(resolvedConfig.slang.dangerouslyAllowSlangInProduction, true); + }); + + it("registers 'slang' as a compiler type", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", settings: {} }], + overrides: {}, + }, + }), + ); + + assert.deepEqual( + resolvedConfig.solidity.registeredCompilerTypes, + ["solc", "slang"], + "the plugin registers 'slang' and leaves core's 'solc' in place, and registers nothing else", + ); + }); + + 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.34", settings: {} }], + overrides: {}, + }, + }), + ); + + const profileNames = Object.keys(resolvedConfig.solidity.profiles); + assert.deepEqual(profileNames, ["default"]); + }); + + it("adds slang debugInfo selectors to slang-typed compilers in resolved config", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { outputSelection: { "*": { "*": ["abi"] } } }, + }, + ], + overrides: {}, + }, + }), + ); + + const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; + const wildcardSelectors = slangCompiler.settings.outputSelection["*"][ + "*" + ] as string[]; + for (const selector of SLANG_DEBUG_INFO_SELECTORS) { + assert.ok( + wildcardSelectors.includes(selector), + `expected resolved slang compiler config to include "${selector}", got: ${wildcardSelectors.join(", ")}`, + ); + } + assert.ok( + wildcardSelectors.includes("abi"), + "user-provided selectors must be preserved alongside the augmentation", + ); + }); + + it("does NOT add slang selectors to non-slang compilers", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [ + { + version: "0.8.34", + settings: { outputSelection: { "*": { "*": ["abi"] } } }, + }, + ], + overrides: {}, + }, + }), + ); + + const solcCompiler = resolvedConfig.solidity.profiles.default.compilers[0]; + const wildcardSelectors = solcCompiler.settings.outputSelection["*"][ + "*" + ] as string[]; + for (const selector of SLANG_DEBUG_INFO_SELECTORS) { + assert.ok( + !wildcardSelectors.includes(selector), + `solc-typed compiler must NOT receive slang selector "${selector}"; got: ${wildcardSelectors.join(", ")}`, + ); + } + }); + + it("augments slang-typed override entries too", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: { + "contracts/Special.sol": { + version: "0.8.34", + type: "slang", + settings: {}, + }, + }, + }, + }), + ); + + const override = + resolvedConfig.solidity.profiles.slang.overrides["contracts/Special.sol"]; + const wildcardSelectors = override.settings.outputSelection["*"][ + "*" + ] as string[]; + for (const selector of SLANG_DEBUG_INFO_SELECTORS) { + assert.ok( + wildcardSelectors.includes(selector), + `expected slang override to include "${selector}", got: ${wildcardSelectors.join(", ")}`, + ); + } + }); + + it("defaults the optimizer mode on slang-typed compilers in resolved config", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + }), + ); + + const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; + assert.equal(slangCompiler.settings.optimizer.mode, "1"); + }); + + it("lets a user-set optimizer mode win over the slang default", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { optimizer: { enabled: true, mode: "z" } }, + }, + ], + overrides: {}, + }, + }), + ); + + const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; + assert.deepEqual(slangCompiler.settings.optimizer, { + enabled: true, + mode: "z", + }); + }); + + it("fills the optimizer mode without clobbering other user optimizer fields", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { optimizer: { enabled: true } }, + }, + ], + overrides: {}, + }, + }), + ); + + const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; + assert.deepEqual(slangCompiler.settings.optimizer, { + enabled: true, + mode: "1", + }); + }); + + it("does not let an undefined user optimizer mode clobber the default", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { optimizer: { enabled: true, mode: undefined } }, + }, + ], + overrides: {}, + }, + }), + ); + + const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; + assert.equal(slangCompiler.settings.optimizer.mode, "1"); + }); + + it("defaults viaIR to false on slang-typed compilers, letting a user value win", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: { + "contracts/ViaIR.sol": { + version: "0.8.34", + type: "slang", + settings: { viaIR: true }, + }, + }, + }, + }), + ); + + const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; + assert.equal(slangCompiler.settings.viaIR, false); + const override = + resolvedConfig.solidity.profiles.slang.overrides["contracts/ViaIR.sol"]; + assert.equal(override.settings.viaIR, true); + assert.equal(override.settings.optimizer.mode, "1"); + }); + + it("preserves user-set settings (e.g. evmVersion) while adding slang defaults", async () => { + const resolvedConfig = await resolveUserConfig( + {}, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "prague" }, + }, + ], + overrides: {}, + }, + }), + ); + + const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; + // An arbitrary user solc setting survives config resolution untouched... + assert.equal(slangCompiler.settings.evmVersion, "prague"); + // ...alongside a default the plugin fills in (the mode default has its own test). + assert.equal(slangCompiler.settings.viaIR, false); + }); +}); + +describe("hardhat-slang EVM version validation", () => { + it("rejects type: 'slang' with pre-cancun evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + 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: 'slang' with shanghai evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "shanghai" }, + }, + ], + }, + }, + }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + }); + + it("accepts type: 'slang' with cancun evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "cancun" }, + }, + ], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts type: 'slang' with prague evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "prague" }, + }, + ], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts type: 'slang' with osaka evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "osaka" }, + }, + ], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("accepts type: 'slang' without evmVersion", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [{ version: "0.8.34", type: "slang" }], + }, + }, + }, + }); + assert.deepEqual(errors, []); + }); + + it("ignores evmVersion on non-slang compiler entries", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + 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: { + slang: { + compilers: [{ version: "0.8.34" }], + overrides: { + "contracts/Old.sol": { + version: "0.8.34", + type: "slang", + 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-slang Solidity version validation", () => { + it("rejects type: 'slang' with unsupported Solidity version", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [{ version: "0.8.28", type: "slang" }], + }, + }, + }, + }); + assert.ok( + errors.some((e) => e.message.includes("Slang only supports versions")), + `Expected Solidity version error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("accepts type: 'slang' with supported Solidity version 0.8.34", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [{ version: "0.8.34", type: "slang" }], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Slang only supports versions"), + ); + assert.deepEqual(versionErrors, []); + }); + + it("accepts type: 'slang' with supported version and custom path", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + path: "/tmp/slang-custom", + }, + ], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Slang only supports versions"), + ); + assert.deepEqual(versionErrors, []); + }); + + it("accepts type: 'slang' with unsupported version when path is set", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.35", + type: "slang", + path: "/tmp/slang-nightly", + }, + ], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Slang only supports versions"), + ); + assert.deepEqual(versionErrors, []); + }); + + it("rejects unsupported version when path is empty string", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [{ version: "0.8.35", type: "slang", path: "" }], + }, + }, + }, + }); + const versionErrors = errors.filter((e) => + e.message.includes("Slang only supports versions"), + ); + assert.ok( + versionErrors.length > 0, + "Expected version validation error for empty path", + ); + }); +}); + +describe("hardhat-slang resolved config validation", () => { + function makeResolvedConfig( + profiles: Record, + opts?: { dangerouslyAllowSlangInProduction?: boolean }, + ): any { + return { + solidity: { + profiles, + npmFilesToBuild: [], + registeredCompilerTypes: ["solc", "slang"], + }, + slang: { + dangerouslyAllowSlangInProduction: + opts?.dangerouslyAllowSlangInProduction ?? false, + }, + }; + } + + it("errors when no 'slang' build profile exists", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", settings: {} }], + overrides: {}, + }, + }), + ); + assert.ok(errors.length > 0, "Should have validation errors"); + assert.ok( + errors.some((e) => e.message.includes('no "slang" build profile')), + `Expected missing slang profile error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("passes when 'slang' build profile exists", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", settings: {} }], + overrides: {}, + }, + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + }), + ); + assert.deepEqual(errors, []); + }); + + it("errors when type: 'slang' appears in a non-slang profile", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + }), + ); + assert.ok( + errors.some((e) => + e.message.includes('only supported in the "slang" build profile'), + ), + `Expected non-slang 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: 'slang' appears in non-slang profile overrides", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", settings: {} }], + overrides: { + "MyContract.sol": { + version: "0.8.34", + type: "slang", + settings: {}, + }, + }, + }, + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + }), + ); + assert.ok( + errors.some((e) => + e.message.includes('only supported in the "slang" build profile'), + ), + `Expected non-slang 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: 'slang' in non-slang profiles with dangerouslyAllowSlangInProduction", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig( + { + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + }, + { dangerouslyAllowSlangInProduction: true }, + ), + ); + assert.deepEqual(errors, []); + }); + + it("allows type: 'slang' in the slang profile", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", settings: {} }], + overrides: {}, + }, + slang: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + }), + ); + assert.deepEqual(errors, []); + }); + + it("still requires slang profile even with dangerouslyAllowSlangInProduction", async () => { + const errors = await validateResolvedConfig( + makeResolvedConfig( + { + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", type: "slang", settings: {} }], + overrides: {}, + }, + }, + { dangerouslyAllowSlangInProduction: true }, + ), + ); + assert.ok( + errors.some((e) => e.message.includes('no "slang" build profile')), + `Should still require slang profile, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); +}); + +describe("hardhat-slang optimizer mode validation", () => { + async function validateMode(mode: string) { + return await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { optimizer: { mode } }, + }, + ], + }, + }, + }, + }); + } + + for (const mode of ["1", "2", "3", "s", "z"]) { + it(`accepts the optimizer mode "${mode}"`, async () => { + assert.deepEqual(await validateMode(mode), []); + }); + } + + // The likeliest mistake: there is no mode that turns optimization off. + it('rejects the optimizer mode "0"', async () => { + const errors = await validateMode("0"); + + assert.ok( + errors.some((e) => e.message.includes("optimizer modes")), + `Expected an optimizer mode error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("rejects an uppercase size mode, which slang does not accept", async () => { + const errors = await validateMode("Z"); + + assert.ok( + errors.some((e) => e.message.includes("optimizer modes")), + `Expected an optimizer mode error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("leaves the other optimizer settings alone", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { + compilers: [ + { + version: "0.8.34", + type: "slang", + settings: { optimizer: { enabled: true, runs: 200 } }, + }, + ], + }, + }, + }, + }); + + assert.deepEqual(errors, []); + }); +}); diff --git a/packages/hardhat-slang/test/downloader.ts b/packages/hardhat-slang/test/downloader.ts new file mode 100644 index 00000000000..a10a98677e4 --- /dev/null +++ b/packages/hardhat-slang/test/downloader.ts @@ -0,0 +1,274 @@ +import type { + Interceptable, + TestDispatcher, +} from "@nomicfoundation/hardhat-utils/request"; + +import assert from "node:assert/strict"; +import { stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { + assertRejectsWithHardhatError, + makeWorkspaceTmpDir, + safeRemoveTmpDir, +} from "@nomicfoundation/hardhat-test-utils"; +import { sha256 } from "@nomicfoundation/hardhat-utils/crypto"; +import { ensureDir, exists } from "@nomicfoundation/hardhat-utils/fs"; +import { + resetMockCacheDir, + setMockCacheDir, +} from "@nomicfoundation/hardhat-utils/global-dir"; +import { bytesToHexString } from "@nomicfoundation/hardhat-utils/hex"; +import { getTestDispatcher } from "@nomicfoundation/hardhat-utils/request"; + +import { SLANG_RELEASES_BASE_URL } from "../src/internal/constants.js"; +import { + downloadSlang, + getSlangBinaryPath, +} from "../src/internal/downloader.js"; +import { getSlangAssetName } from "../src/internal/platform.js"; + +const TEST_SLANG_VERSION = "0.0.1-test"; + +const BINARY_CONTENTS = "#!/bin/sh\necho slang 0.0.1-test\n"; + +const RETRY_COUNT = 3; + +const noop = (): void => {}; + +describe("hardhat-slang downloader", () => { + let tmpDir: string; + let mockAgent: TestDispatcher; + let assetName: string; + let expectedDigest: string; + + let mockedInterceptor: Interceptable; + + function interceptChecksum( + interceptable: Interceptable, + body: string, + times = 1, + ): void { + interceptable + .intercept({ path: `/${assetName}.sha256`, method: "GET" }) + .reply(200, body) + .times(times); + } + + function interceptBinary( + interceptable: Interceptable, + body: string, + times = 1, + ): void { + interceptable + .intercept({ path: `/${assetName}`, method: "GET" }) + .reply(200, body) + .times(times); + } + + beforeEach(async () => { + tmpDir = await makeWorkspaceTmpDir("slang-downloader"); + setMockCacheDir(tmpDir); + + assetName = getSlangAssetName(TEST_SLANG_VERSION); + expectedDigest = bytesToHexString( + await sha256(Buffer.from(BINARY_CONTENTS)), + ).slice(2); + + mockAgent = await getTestDispatcher(); + mockedInterceptor = mockAgent.get(SLANG_RELEASES_BASE_URL); + + // Any request the tests don't intercept is a bug, not a network call. + mockAgent.disableNetConnect(); + }); + + afterEach(async () => { + mockAgent.enableNetConnect(); + await mockAgent.close(); + + resetMockCacheDir(); + + await safeRemoveTmpDir(tmpDir); + }); + + it("should successfully verify the download against the sidecar and keep the binary", async () => { + interceptChecksum(mockedInterceptor, expectedDigest); + interceptBinary(mockedInterceptor, BINARY_CONTENTS); + + const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { + dispatcher: mockAgent, + }); + + assert.equal(binaryPath, await getSlangBinaryPath(TEST_SLANG_VERSION)); + assert.ok(await exists(binaryPath), "the binary should have been kept"); + + if (process.platform === "win32") { + return; + } + + const { mode } = await stat(binaryPath); + assert.equal( + mode.toString(8).slice(-3), + "755", + "the binary should be executable", + ); + }); + + it("should accept a sha256sum-style sidecar, with the filename after the digest", async () => { + interceptChecksum(mockedInterceptor, `${expectedDigest} ${assetName}`); + interceptBinary(mockedInterceptor, BINARY_CONTENTS); + + const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { + dispatcher: mockAgent, + }); + + assert.ok( + await exists(binaryPath), + "the digest should have been read from before the filename", + ); + }); + + it("should accept an uppercase digest", async () => { + interceptChecksum(mockedInterceptor, expectedDigest.toUpperCase()); + interceptBinary(mockedInterceptor, BINARY_CONTENTS); + + const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { + dispatcher: mockAgent, + }); + + assert.ok( + await exists(binaryPath), + "digest comparison should be case-insensitive", + ); + }); + + it("fails when the sidecar request errors", async () => { + mockedInterceptor + .intercept({ path: `/${assetName}.sha256`, method: "GET" }) + .replyWithError(new Error("socket hang up")); + + await assertRejectsWithHardhatError( + downloadSlang(TEST_SLANG_VERSION, noop, { dispatcher: mockAgent }), + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, + { + version: TEST_SLANG_VERSION, + url: `${SLANG_RELEASES_BASE_URL}/${assetName}.sha256`, + reason: "socket hang up", + }, + ); + }); + + it("should fail without downloading the binary when the sidecar is missing", async () => { + mockedInterceptor + .intercept({ path: `/${assetName}.sha256`, method: "GET" }) + .reply(404, "Not Found"); + + await assertRejectsWithHardhatError( + downloadSlang(TEST_SLANG_VERSION, noop, { dispatcher: mockAgent }), + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, + { + version: TEST_SLANG_VERSION, + url: `${SLANG_RELEASES_BASE_URL}/${assetName}.sha256`, + reason: "the mirror responded with status 404", + }, + ); + + assert.equal( + await exists(await getSlangBinaryPath(TEST_SLANG_VERSION)), + false, + "no binary should be left behind", + ); + }); + + it("fails when the sidecar isn't a digest, without downloading the binary", async () => { + // A proxy serving its own error page with a 200 is the realistic case. + interceptChecksum(mockedInterceptor, "Not Found"); + + await assertRejectsWithHardhatError( + downloadSlang(TEST_SLANG_VERSION, noop, { dispatcher: mockAgent }), + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, + { + version: TEST_SLANG_VERSION, + url: `${SLANG_RELEASES_BASE_URL}/${assetName}.sha256`, + reason: "the response didn't contain a SHA-256 digest", + }, + ); + + assert.equal( + await exists(await getSlangBinaryPath(TEST_SLANG_VERSION)), + false, + "no binary should be left behind", + ); + }); + + it("deletes the binary and fails when the digest doesn't match", async () => { + interceptChecksum(mockedInterceptor, expectedDigest, RETRY_COUNT); + interceptBinary( + mockedInterceptor, + "a different binary entirely", + RETRY_COUNT, + ); + + await assertRejectsWithHardhatError( + downloadSlang(TEST_SLANG_VERSION, noop, { + dispatcher: mockAgent, + retryDelayMs: 0, + }), + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.INVALID_DOWNLOAD, + { version: TEST_SLANG_VERSION }, + ); + + assert.equal( + await exists(await getSlangBinaryPath(TEST_SLANG_VERSION)), + false, + "a binary that failed verification must not be left on disk", + ); + }); + + it("recovers when a retry downloads the binary correctly", async () => { + interceptChecksum(mockedInterceptor, expectedDigest, 2); + interceptBinary(mockedInterceptor, "truncated"); + interceptBinary(mockedInterceptor, BINARY_CONTENTS); + + const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { + dispatcher: mockAgent, + retryDelayMs: 0, + }); + + assert.ok( + await exists(binaryPath), + "the retry should have replaced the truncated download", + ); + }); + + it("leaves no temporary file behind after a successful download", async () => { + interceptChecksum(mockedInterceptor, expectedDigest); + interceptBinary(mockedInterceptor, BINARY_CONTENTS); + + const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { + dispatcher: mockAgent, + }); + + assert.ok( + !(await exists(`${binaryPath}.tmp`)), + "the download path should have been renamed into place, not copied", + ); + }); + + it("returns a cached binary without making any request", async () => { + // No interceptors at all: reaching the network would throw. + const binaryPath = await getSlangBinaryPath(TEST_SLANG_VERSION); + + await ensureDir(path.dirname(binaryPath)); + await writeFile(binaryPath, BINARY_CONTENTS); + + assert.equal( + await downloadSlang(TEST_SLANG_VERSION, noop, { + dispatcher: mockAgent, + }), + binaryPath, + ); + }); +}); diff --git a/packages/hardhat-slang/test/fixture-projects/simple/contracts/Greeter.sol b/packages/hardhat-slang/test/fixture-projects/simple/contracts/Greeter.sol new file mode 100644 index 00000000000..0ab5ca745a3 --- /dev/null +++ b/packages/hardhat-slang/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-slang/test/fixture-projects/simple/hardhat.config.ts b/packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts new file mode 100644 index 00000000000..53586a2ed44 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts @@ -0,0 +1,20 @@ +import type { HardhatUserConfig } from "hardhat/config"; + +import HardhatSlangPlugin from "../../../src/index.js"; + +const config: HardhatUserConfig = { + solidity: { + profiles: { + default: { + version: "0.8.34", + }, + slang: { + type: "slang", + version: "0.8.34", + }, + }, + }, + plugins: [HardhatSlangPlugin], +}; + +export default config; diff --git a/packages/hardhat-slang/test/fixture-projects/simple/package.json b/packages/hardhat-slang/test/fixture-projects/simple/package.json new file mode 100644 index 00000000000..204de7b7f32 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/simple/package.json @@ -0,0 +1,6 @@ +{ + "name": "hardhat-slang-simple-fixture", + "private": true, + "type": "module", + "version": "0.0.1" +} diff --git a/packages/hardhat-slang/test/fixture-projects/with-debug-info/.gitignore b/packages/hardhat-slang/test/fixture-projects/with-debug-info/.gitignore new file mode 100644 index 00000000000..e7f801166c5 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/.gitignore @@ -0,0 +1,2 @@ +artifacts/ +cache/ diff --git a/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/ConstructorRevert.sol b/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/ConstructorRevert.sol new file mode 100644 index 00000000000..d5608e25e18 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/ConstructorRevert.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +// CREATE-time revert via internal helper. Exercises solx DWARF for the +// CREATE bytecode (evm.bytecode.debugInfo). +contract ConstructorRevert { + function _check(uint256 v) internal pure { + require(v > 0, "constructor helper boom"); + } + + constructor(uint256 v) { + _check(v); + } +} diff --git a/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/Counter.sol b/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/Counter.sol new file mode 100644 index 00000000000..96d00cc68c6 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/Counter.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +contract Counter { + uint256 public count; + + function set(uint256 v) public { + _checkPositive(v); + count = v; + } + + function _checkPositive(uint256 v) internal pure { + require(v > 0, "must be positive"); + } +} diff --git a/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/InlineAsm.sol b/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/InlineAsm.sol new file mode 100644 index 00000000000..4b0778fb003 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/contracts/InlineAsm.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +// Inline-assembly revert. Sparser DWARF (no per-opcode rows for assembly). +contract InlineAsm { + function boom() external pure { + assembly { + mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) + mstore(0x04, 0x20) + mstore(0x24, 0x04) + mstore(0x44, 0x61736d00000000000000000000000000000000000000000000000000000000) + revert(0x00, 0x64) + } + } +} diff --git a/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts b/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts new file mode 100644 index 00000000000..53586a2ed44 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts @@ -0,0 +1,20 @@ +import type { HardhatUserConfig } from "hardhat/config"; + +import HardhatSlangPlugin from "../../../src/index.js"; + +const config: HardhatUserConfig = { + solidity: { + profiles: { + default: { + version: "0.8.34", + }, + slang: { + type: "slang", + version: "0.8.34", + }, + }, + }, + plugins: [HardhatSlangPlugin], +}; + +export default config; diff --git a/packages/hardhat-slang/test/fixture-projects/with-debug-info/package.json b/packages/hardhat-slang/test/fixture-projects/with-debug-info/package.json new file mode 100644 index 00000000000..f7f23aea975 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/package.json @@ -0,0 +1,6 @@ +{ + "name": "hardhat-slang-with-debug-info-fixture", + "private": true, + "type": "module", + "version": "0.0.1" +} diff --git a/packages/hardhat-slang/test/hook-handlers/solidity.ts b/packages/hardhat-slang/test/hook-handlers/solidity.ts new file mode 100644 index 00000000000..41485680e76 --- /dev/null +++ b/packages/hardhat-slang/test/hook-handlers/solidity.ts @@ -0,0 +1,308 @@ +/* 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 { parseSlangVersion } from "../../src/internal/hook-handlers/solidity.js"; + +// Helper to create a compiler config +function createSolidityCompilerConfig( + overrides: Partial = {}, +): SolidityCompilerConfig { + return { + version: "0.8.34", + settings: { + optimizer: {}, + outputSelection: {}, + }, + ...overrides, + }; +} + +// A mock "next" function for getCompiler +function createGetCompilerMockNext() { + let called = false; + const mockCompiler = { + version: "0.8.34", + longVersion: "0.8.34+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("parseSlangVersion", () => { + it("parses version from stable release output", () => { + assert.equal( + parseSlangVersion( + "slang, 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( + parseSlangVersion( + "slang 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( + parseSlangVersion("slang v0.2.0-alpha.1, LLVM-based Solidity compiler"), + "0.2.0-alpha.1", + ); + }); +}); + +describe("hardhat-slang 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 slang-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: "slang", version: "0.8.34" }), + ]; + + // 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 downloadSlang, 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-slang 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 slang 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: "slang", + version: "0.8.99", + }); + const mockNext = createGetCompilerMockNext(); + + await assertRejectsWithHardhatError( + hooks.getCompiler!(context, compilerConfig, mockNext.next), + HardhatError.ERRORS.CORE.INTERNAL.ASSERTION_ERROR, + { + message: + "No slang 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: "slang", + version: "0.8.34", + path: "/nonexistent/path/to/slang", + }); + const mockNext = createGetCompilerMockNext(); + + await assertRejectsWithHardhatError( + hooks.getCompiler!(context, compilerConfig, mockNext.next), + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.BINARY_NOT_FOUND, + { + path: "/nonexistent/path/to/slang", + }, + ); + }); + + it("returns SlangCompiler with version from binary when path is provided", async () => { + const { getSlangBinaryPath } = + await import("../../src/internal/downloader.js"); + const { exists } = await import("@nomicfoundation/hardhat-utils/fs"); + + // Use the cached slang binary if available, skip otherwise + const cachedPath = await getSlangBinaryPath("0.1.4"); + 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: "slang", + version: "0.8.34", + 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 slang type", + ); + assert.equal(compiler.compilerPath, cachedPath); + // Version should be parsed from the binary, not from config + assert.equal(compiler.version, "0.1.4"); + assert.equal(compiler.longVersion, "0.1.4+slang"); + }); + }); + + 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: "slang", + version: "0.8.34", + path: "/custom/path/to/slang", + }), + ]; + + // Should not throw β€” download is skipped for configs with path + await hooks.downloadCompilers!(context, configs, true); + }); + }); +}); diff --git a/packages/hardhat-slang/test/integration.ts b/packages/hardhat-slang/test/integration.ts new file mode 100644 index 00000000000..497debe6ffb --- /dev/null +++ b/packages/hardhat-slang/test/integration.ts @@ -0,0 +1,86 @@ +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-slang integration", () => { + useFixtureProject("simple"); + + async function createHre() { + const configPath = await resolveHardhatConfigPath(); + const userConfig = await importUserConfig(configPath); + return await createHardhatRuntimeEnvironment(userConfig); + } + + it("resolves plugin config through the HRE", async () => { + const hre = await createHre(); + assert.equal(hre.config.slang.dangerouslyAllowSlangInProduction, false); + }); + + it("resolves plugin config with defaults when not specified", async () => { + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + profiles: { + default: { + version: "0.8.34", + }, + slang: { + type: "slang", + version: "0.8.34", + }, + }, + }, + plugins: [(await import("../src/index.js")).default], + }); + + assert.equal(hre.config.slang.dangerouslyAllowSlangInProduction, 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 'slang' build profile in resolved config", async () => { + const hre = await createHre(); + + const profileNames = Object.keys(hre.config.solidity.profiles); + assert.ok( + profileNames.includes("slang"), + `Expected "slang" profile in: ${profileNames.join(", ")}`, + ); + + const slangProfile = hre.config.solidity.profiles.slang; + assert.equal( + slangProfile.compilers[0].type, + "slang", + "slang profile compiler should have type: 'slang'", + ); + }); + + it("registers 'slang' as a compiler type", async () => { + const hre = await createHre(); + + assert.deepEqual( + hre.config.solidity.registeredCompilerTypes, + ["solc", "slang"], + "the plugin registers 'slang' and leaves core's 'solc' in place, and registers nothing else", + ); + }); +}); diff --git a/packages/hardhat-slang/test/mirror-availability.ts b/packages/hardhat-slang/test/mirror-availability.ts new file mode 100644 index 00000000000..1e5fc1930bb --- /dev/null +++ b/packages/hardhat-slang/test/mirror-availability.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + getRequest, + ResponseStatusCodeError, +} from "@nomicfoundation/hardhat-utils/request"; + +import { + SOLIDITY_TO_SOLX_VERSION_MAP, + SLANG_RELEASES_BASE_URL, +} from "../src/internal/constants.js"; + +// Mirrors the per-platform asset names built in src/internal/platform.ts. +function assetNames(version: string): string[] { + return [ + `solx-linux-amd64-gnu-v${version}`, + `solx-linux-arm64-gnu-v${version}`, + `solx-macosx-v${version}`, + `solx-windows-amd64-gnu-v${version}.exe`, + ]; +} + +describe( + "slang releases mirror availability", + // Opt-in: run by the path-filtered slang-mirror-availability job in ci.yml, + // not on every PR that rebuilds this package. + { skip: process.env.HARDHAT_RUN_MIRROR_TESTS !== "true" }, + () => { + for (const [solidityVersion, slangVersion] of Object.entries( + SOLIDITY_TO_SOLX_VERSION_MAP, + )) { + it(`serves every slang ${slangVersion} asset and checksum (mapped from Solidity ${solidityVersion})`, async () => { + const missing: string[] = []; + for (const asset of assetNames(slangVersion)) { + // Sidecars too: the downloader refuses to use a binary it can't verify + for (const file of [asset, `${asset}.sha256`]) { + try { + const response = await getRequest( + `${SLANG_RELEASES_BASE_URL}/${file}`, + // 1-byte range: don't pull the ~60 MB binaries. + { extraHeaders: { Range: "bytes=0-0" } }, + // isTestDispatcher drops keep-alive to 10ms; these dispatchers + // are never closed and would otherwise hang the suite. + { timeout: 30_000, isTestDispatcher: true }, + ); + if (response.statusCode !== 200 && response.statusCode !== 206) { + missing.push(`${file} (${response.statusCode})`); + } + await response.body.text(); + } catch (error) { + // getRequest throws on >= 400 instead of returning the status. + if (!(error instanceof ResponseStatusCodeError)) { + throw error; + } + missing.push(`${file} (${error.statusCode})`); + } + } + } + assert.deepEqual( + missing, + [], + `the mirror does not serve: ${missing.join(", ")} β€” the version map must not point at a slang release before solx-releases-mirror serves its assets`, + ); + }); + } + }, +); diff --git a/packages/hardhat-slang/test/optimizer-mode.ts b/packages/hardhat-slang/test/optimizer-mode.ts new file mode 100644 index 00000000000..746d348fcef --- /dev/null +++ b/packages/hardhat-slang/test/optimizer-mode.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- test reads +the slang-specific `optimizer.mode`, which isn't on hardhat's CompilerInput type */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { useEphemeralFixtureProject } from "@nomicfoundation/hardhat-test-utils"; +import { + createHardhatRuntimeEnvironment, + importUserConfig, + resolveHardhatConfigPath, +} from "hardhat/hre"; + +// Proves the plugin's default optimizer.mode reaches the resolved solcInput +// (which hardhat persists as build-info) through the real pipeline β€” asserting +// the compiler *input*, not compiled output, and without spawning slang. +describe("hardhat-slang optimizer mode reaches the solc input", () => { + useEphemeralFixtureProject("simple"); + + it("defaults optimizer.mode to -O1 in the slang profile's solcInput", async () => { + const configPath = await resolveHardhatConfigPath(); + const userConfig = await importUserConfig(configPath); + const hre = await createHardhatRuntimeEnvironment(userConfig); + + const rootFilePaths = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + + const jobsResult = await hre.solidity.getCompilationJobs(rootFilePaths, { + force: true, + quiet: true, + buildProfile: "slang", + }); + assert.ok( + jobsResult.success, + "getCompilationJobs should succeed for the slang profile", + ); + + const job = [...jobsResult.compilationJobsPerFile.values()][0]; + const solcInput = await job.getSolcInput(); + + const optimizer = solcInput.settings.optimizer as + { mode?: string } | undefined; + assert.equal( + optimizer?.mode, + "1", + "the plugin's default -O1 should reach the resolved solcInput (and thus build-info)", + ); + assert.equal( + (solcInput.settings as { viaIR?: boolean }).viaIR, + false, + "the plugin's default viaIR:false should also reach the resolved solcInput", + ); + }); +}); diff --git a/packages/hardhat-slang/test/output-augmentation.ts b/packages/hardhat-slang/test/output-augmentation.ts new file mode 100644 index 00000000000..c80b27c78ce --- /dev/null +++ b/packages/hardhat-slang/test/output-augmentation.ts @@ -0,0 +1,146 @@ +import type { + CompilationJob, + CompilerOutputContract, +} from "hardhat/types/solidity"; + +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { useFixtureProject } from "@nomicfoundation/hardhat-test-utils"; +import { + createHardhatRuntimeEnvironment, + importUserConfig, + resolveHardhatConfigPath, +} from "hardhat/hre"; + +// Side-effect-import the type augmentation for `debugInfo`. +import "../src/type-extensions.js"; + +// `\x7fELF` magic hex-encoded β€” slang debugInfo is a hex-encoded ELF blob. +const ELF_MAGIC_HEX = "7f454c46"; + +describe( + "hardhat-slang output augmentation", + { skip: process.env.HARDHAT_DISABLE_SLOW_TESTS === "true" }, + () => { + useFixtureProject("with-debug-info"); + + async function createHre() { + const configPath = await resolveHardhatConfigPath(); + const userConfig = await importUserConfig(configPath); + return await createHardhatRuntimeEnvironment(userConfig); + } + + // Each fixture exercises a different bytecode shape: Counter (runtime), + // ConstructorRevert (CREATE), InlineAsm (assembly). + const FIXTURES: ReadonlyArray<{ source: string; contract: string }> = [ + { source: "Counter.sol", contract: "Counter" }, + { source: "ConstructorRevert.sol", contract: "ConstructorRevert" }, + { source: "InlineAsm.sol", contract: "InlineAsm" }, + ]; + + it("slang-compiled artifacts carry evm.bytecode.debugInfo and evm.deployedBytecode.debugInfo", async () => { + const hre = await createHre(); + + const rootFilePaths = await hre.solidity.getRootFilePaths({ + scope: "contracts", + }); + const fixturePaths = FIXTURES.map(({ source }) => { + const found = rootFilePaths.find((p) => path.basename(p) === source); + assert.ok( + found !== undefined, + `${source} should be a build root, got: ${rootFilePaths.join(", ")}`, + ); + return found; + }); + + const jobsResult = await hre.solidity.getCompilationJobs(fixturePaths, { + force: true, + quiet: true, + buildProfile: "slang", + }); + assert.ok( + jobsResult.success, + "getCompilationJobs should succeed for the slang profile", + ); + + // The fixtures are independent (no shared imports) so they can land in + // separate jobs; run each one and merge outputs by source path. + const seenJobs = new Set(); + const mergedContracts: Record< + string, + Record + > = {}; + const mergedErrors: Array<{ severity: string; message: string }> = []; + for (const job of jobsResult.compilationJobsPerFile.values()) { + if (seenJobs.has(job)) continue; + seenJobs.add(job); + const { output } = await hre.solidity.runCompilationJob(job, { + quiet: true, + buildProfile: "slang", + }); + for (const e of output.errors ?? []) { + mergedErrors.push(e); + } + for (const [src, contracts] of Object.entries(output.contracts ?? {})) { + mergedContracts[src] = { + ...(mergedContracts[src] ?? {}), + ...contracts, + }; + } + } + + const errors = mergedErrors.filter((e) => e.severity === "error"); + assert.equal( + errors.length, + 0, + `slang compilation produced errors: ${errors.map((e) => e.message).join(", ")}`, + ); + + for (const { source, contract } of FIXTURES) { + const sourcePath = `project/contracts/${source}`; + const compiled = mergedContracts[sourcePath]?.[contract]; + assert.ok( + compiled !== undefined, + `${contract} not found at ${sourcePath}. Sources: ${Object.keys(mergedContracts).join(", ")}`, + ); + + const evm = compiled.evm; + assert.ok(evm !== undefined, `${contract}: expected evm in output`); + + const { bytecode, deployedBytecode } = evm; + assert.ok(bytecode !== undefined, `${contract}: expected evm.bytecode`); + assert.ok( + deployedBytecode !== undefined, + `${contract}: expected evm.deployedBytecode`, + ); + + const { debugInfo: creationDebugInfo } = bytecode; + const { debugInfo: runtimeDebugInfo } = deployedBytecode; + + // Core assertion: the plugin must add debugInfo to outputSelection so + // EDR can render slang-aware stack traces. solc artifacts wouldn't + // carry this field at all; slang artifacts must. + assert.ok( + creationDebugInfo !== undefined && creationDebugInfo.length > 0, + `${contract}: expected evm.bytecode.debugInfo to be a non-empty hex string`, + ); + assert.ok( + runtimeDebugInfo !== undefined && runtimeDebugInfo.length > 0, + `${contract}: expected evm.deployedBytecode.debugInfo to be a non-empty hex string`, + ); + + // Sanity-check the blobs are hex-encoded ELFs (EDR's wire format). + assert.ok( + creationDebugInfo.toLowerCase().startsWith(ELF_MAGIC_HEX), + `${contract}: evm.bytecode.debugInfo should start with the ELF magic bytes (${ELF_MAGIC_HEX})`, + ); + assert.ok( + runtimeDebugInfo.toLowerCase().startsWith(ELF_MAGIC_HEX), + `${contract}: evm.deployedBytecode.debugInfo should start with the ELF magic bytes (${ELF_MAGIC_HEX})`, + ); + } + }); + }, +); diff --git a/packages/hardhat-slang/test/platform.ts b/packages/hardhat-slang/test/platform.ts new file mode 100644 index 00000000000..f6d24b115fa --- /dev/null +++ b/packages/hardhat-slang/test/platform.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + getSlangAssetName, + getSlangBinaryBaseName, +} from "../src/internal/platform.js"; + +describe("hardhat-slang platform detection", () => { + it("returns a valid base name for the current platform", () => { + const baseName = getSlangBinaryBaseName(); + 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 = getSlangBinaryBaseName(); + 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 = getSlangAssetName("0.1.4"); + assert.ok( + assetName.includes("-v0.1.4"), + `asset name should include version suffix: ${assetName}`, + ); + assert.ok( + assetName.startsWith("solx-"), + `asset name should start with 'solx-': ${assetName}`, + ); + }); +}); diff --git a/packages/hardhat-slang/test/slang-compiler.ts b/packages/hardhat-slang/test/slang-compiler.ts new file mode 100644 index 00000000000..dd5b2f445f1 --- /dev/null +++ b/packages/hardhat-slang/test/slang-compiler.ts @@ -0,0 +1,124 @@ +import type { CompilerInput, CompilerOutput } from "hardhat/types/solidity"; + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + SLANG_DEBUG_INFO_SELECTORS, + SlangCompiler, + addSlangDebugInfoSelectors, +} from "../src/internal/slang-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("SlangCompiler", () => { + beforeEach(() => { + spawnCompileCalls = []; + }); + + it("implements the Compiler interface", async () => { + const compiler = new SlangCompiler("0.1.4", "/path/to/slang"); + + assert.equal(compiler.version, "0.1.4"); + assert.equal(compiler.longVersion, "0.1.4+slang"); + assert.equal(compiler.compilerPath, "/path/to/slang"); + assert.equal(compiler.isSolcJs, false); + }); + + it("forwards binary path, args, and the input unchanged to spawnCompile", async () => { + const compiler = new SlangCompiler( + "0.1.4", + "/path/to/slang", + 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/slang"); + assert.deepEqual(call.args, ["--standard-json", "--no-import-callback"]); + // compile() transforms nothing β€” slang's defaults (optimizer mode, viaIR, + // debugInfo) are applied at config resolution, so the input reaches slang + // exactly as the build system constructed it. + assert.equal(call.input, input); + }); + + it("returns the output from spawnCompile", async () => { + const compiler = new SlangCompiler( + "0.1.4", + "/path/to/slang", + 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); + }); +}); + +describe("addSlangDebugInfoSelectors", () => { + it("populates the wildcard slot when the input is empty", async () => { + const result = await addSlangDebugInfoSelectors({}); + assert.deepEqual(result, { + "*": { "*": [...SLANG_DEBUG_INFO_SELECTORS] }, + }); + }); + + it("appends to an existing wildcard selector list without removing user entries", async () => { + const result = await addSlangDebugInfoSelectors({ + "*": { "*": ["abi", "metadata"] }, + }); + assert.deepEqual(result, { + "*": { "*": ["abi", "metadata", ...SLANG_DEBUG_INFO_SELECTORS] }, + }); + }); + + it('preserves the file-level `[*][""]` slot for outputs like ast', async () => { + const result = await addSlangDebugInfoSelectors({ + "*": { "": ["ast"] }, + }); + // The file-level slot must round-trip unchanged. Selectors are added at + // the per-contract slot `["*"]["*"]` instead. + assert.ok(result !== undefined, "result should not be undefined"); + assert.deepEqual(result["*"][""], ["ast"]); + }); + + it("does not mutate the input object", async () => { + const input = { "*": { "*": ["abi"] } }; + const before = JSON.stringify(input); + await addSlangDebugInfoSelectors(input); + assert.equal(JSON.stringify(input), before); + }); + + it("accepts undefined input (sets up the wildcard slot)", async () => { + const result = await addSlangDebugInfoSelectors(undefined); + assert.deepEqual(result, { + "*": { "*": [...SLANG_DEBUG_INFO_SELECTORS] }, + }); + }); +}); diff --git a/packages/hardhat-slang/tsconfig.json b/packages/hardhat-slang/tsconfig.json new file mode 100644 index 00000000000..4796cdc5061 --- /dev/null +++ b/packages/hardhat-slang/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 3ab33835a94..bff4dad562f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1166,6 +1166,52 @@ importers: specifier: ~6.0.3 version: 6.0.3 + packages/hardhat-slang: + dependencies: + '@nomicfoundation/hardhat-errors': + specifier: workspace:^3.1.0 + version: link:../hardhat-errors + '@nomicfoundation/hardhat-utils': + specifier: workspace:^4.3.0 + version: link:../hardhat-utils + '@nomicfoundation/hardhat-zod-utils': + specifier: workspace:^3.0.5 + version: link:../hardhat-zod-utils + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@nomicfoundation/hardhat-node-test-reporter': + specifier: workspace:^3.1.0 + version: link:../hardhat-node-test-reporter + '@nomicfoundation/hardhat-test-utils': + specifier: workspace:^ + version: link:../hardhat-test-utils + '@types/node': + specifier: ^22.0.0 + version: 22.20.4 + c8: + specifier: ^12.0.0 + version: 12.0.0 + eslint: + specifier: 9.39.5 + version: 9.39.5(supports-color@8.1.1) + hardhat: + specifier: workspace:^3.15.0 + version: link:../hardhat + prettier: + specifier: 3.9.8 + version: 3.9.8 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + tsx: + specifier: ^4.23.1 + version: 4.23.15 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + packages/hardhat-slang-solx: dependencies: '@nomicfoundation/hardhat-errors': From f5a237d4d8ed52bee84fa723693491ec4e6f9c62 Mon Sep 17 00:00:00 2001 From: Marian Fechete Date: Thu, 1 Oct 2026 18:06:22 +0200 Subject: [PATCH 2/9] Add pinned slang release and Solidity range validation to hardhat-slang config --- packages/hardhat-slang/package.json | 1 + .../hardhat-slang/src/internal/constants.ts | 44 ++ .../src/internal/hook-handlers/config.ts | 168 +++++- packages/hardhat-slang/src/type-extensions.ts | 14 + packages/hardhat-slang/test/config.ts | 569 +++++++++++------- .../fixture-projects/simple/hardhat.config.ts | 3 + .../with-debug-info/hardhat.config.ts | 3 + packages/hardhat-slang/test/integration.ts | 43 ++ packages/hardhat-slang/test/optimizer-mode.ts | 4 +- packages/hardhat-slang/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 11 files changed, 611 insertions(+), 244 deletions(-) diff --git a/packages/hardhat-slang/package.json b/packages/hardhat-slang/package.json index 1a976392cd4..652bf7e8213 100644 --- a/packages/hardhat-slang/package.json +++ b/packages/hardhat-slang/package.json @@ -46,6 +46,7 @@ ], "devDependencies": { "@nomicfoundation/hardhat-node-test-reporter": "workspace:^3.1.0", + "@nomicfoundation/hardhat-slang-solx": "workspace:^", "@nomicfoundation/hardhat-test-utils": "workspace:^", "@types/node": "^22.0.0", "c8": "^12.0.0", diff --git a/packages/hardhat-slang/src/internal/constants.ts b/packages/hardhat-slang/src/internal/constants.ts index e2e2b068b11..6b1e5cb29db 100644 --- a/packages/hardhat-slang/src/internal/constants.ts +++ b/packages/hardhat-slang/src/internal/constants.ts @@ -40,3 +40,47 @@ export const DEFAULT_SLANG_OPTIMIZER_MODE = "1"; export const SOLIDITY_TO_SOLX_VERSION_MAP: Record = { "0.8.34": "0.1.8", }; + +/** + * A slang release the plugin knows how to download and drive. slang compiles + * a range of Solidity versions with one binary, so each release maps to an + * inclusive `[minSolidity, maxSolidity]` range rather than a single version. + */ +export interface SlangRelease { + /** Lowest Solidity version this release compiles, inclusive. */ + minSolidity: string; + /** Highest Solidity version this release compiles, inclusive. */ + maxSolidity: string; + /** CLI arguments passed on every compile, after `--standard-json`. */ + extraArgs: readonly string[]; + /** + * The CLI flag that receives the compilation job's Solidity version, e.g. + * `--solidity-version`. Absent when the release doesn't accept one. + */ + targetVersionFlag?: string; + /** + * Where to download the release from, when it isn't served by the mirror + * under the standard `v${version}` asset naming. + */ + assetOverride?: { + baseUrl: string; + assetSuffix: string; + }; +} + +/** + * The slang releases this plugin can pin via `slang.version`. + */ +// TODO: revisit before release to finalize. +export const SLANG_RELEASES: Record = { + "0.1.0-pre.2026-10-01": { + minSolidity: "0.8.0", + maxSolidity: "0.8.37", + extraArgs: ["--no-import-callback"], + assetOverride: { + baseUrl: + "https://github.com/NomicFoundation/solx/releases/download/b74af542", + assetSuffix: "slang-2026-10-01", + }, + }, +}; diff --git a/packages/hardhat-slang/src/internal/hook-handlers/config.ts b/packages/hardhat-slang/src/internal/hook-handlers/config.ts index 09dea837563..3e520320549 100644 --- a/packages/hardhat-slang/src/internal/hook-handlers/config.ts +++ b/packages/hardhat-slang/src/internal/hook-handlers/config.ts @@ -1,8 +1,10 @@ +import type { SlangRelease } from "../constants.js"; import type { ConfigurationVariableResolver, HardhatConfig, HardhatUserConfig, SlangConfig, + SlangUserConfig, } from "hardhat/types/config"; import type { ConfigHooks, @@ -10,7 +12,13 @@ import type { HardhatUserConfigValidationError, } from "hardhat/types/hooks"; +import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors"; import { createDebug } from "@nomicfoundation/hardhat-utils/debug"; +import { + greaterThanOrEqual, + lowerThanOrEqual, + parseVersion, +} from "@nomicfoundation/hardhat-utils/fast-semver"; import { isObject } from "@nomicfoundation/hardhat-utils/lang"; import { conditionalUnionType, @@ -20,8 +28,8 @@ import { z } from "zod"; import { DEFAULT_SLANG_OPTIMIZER_MODE, - SOLIDITY_TO_SOLX_VERSION_MAP, SLANG_COMPILER_TYPE, + SLANG_RELEASES, SUPPORTED_SLANG_EVM_VERSIONS, SUPPORTED_SLANG_OPTIMIZER_MODES, } from "../constants.js"; @@ -32,9 +40,7 @@ const log = createDebug("hardhat:slang: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 KNOWN_SLANG_VERSIONS = Object.keys(SLANG_RELEASES); const supportedEvmVersionsType = z .string() @@ -64,16 +70,7 @@ const slangSolidityCompilerUserConfigType = z .passthrough() .optional(), }) - .passthrough() - .refine( - (data) => - (typeof data.path === "string" && data.path.length > 0) || - SUPPORTED_VERSIONS.includes(data.version), - { - message: `Slang only supports versions: ${SUPPORTED_VERSIONS.join(", ")}`, - path: ["version"], - }, - ); + .passthrough(); const solidityCompilerUserConfigType = conditionalUnionType( [ @@ -159,15 +156,138 @@ const solidityUserConfigType = conditionalUnionType( "Expected a version string, an array of version strings, or an object configuring one or more versions of Solidity or multiple build profiles", ); -const slangUserConfigType = z.object({ - solidity: solidityUserConfigType.optional(), - slang: z - .object({ - dangerouslyAllowSlangInProduction: z.boolean().optional(), - }) - .optional(), +const slangVersionType = z.string().superRefine((version, ctx) => { + if (!(version in SLANG_RELEASES)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unknown slang version "${version}". Known slang releases: ${KNOWN_SLANG_VERSIONS.join(", ")}`, + }); + } }); +const slangUserConfigType = z + .object({ + solidity: solidityUserConfigType.optional(), + slang: z + .object({ + version: slangVersionType.optional(), + dangerouslyAllowSlangInProduction: z.boolean().optional(), + }) + .optional(), + }) + .superRefine((config, ctx) => { + const entries = collectSlangEntriesToDownload(config.solidity); + if (entries.length === 0) { + return; + } + + const pinnedVersion = config.slang?.version; + if (pinnedVersion === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["slang", "version"], + message: `slang.version is required when a compiler entry uses type: "${SLANG_COMPILER_TYPE}" without a custom path. Known slang releases: ${KNOWN_SLANG_VERSIONS.join(", ")}`, + }); + return; + } + + const release = SLANG_RELEASES[pinnedVersion]; + if (release === undefined) { + // Already reported by slangVersionType. + return; + } + + for (const { version, path } of entries) { + if (!isSolidityVersionSupportedBy(version, release)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [...path, "version"], + message: `Solidity version ${version} is not supported by slang ${pinnedVersion}, which supports versions from ${release.minSolidity} to ${release.maxSolidity}`, + }); + } + } + }); + +interface SlangEntryToDownload { + version: string; + path: Array; +} + +/** + * Collects every compiler entry with `type: "slang"` and no custom `path` + * from any of the shapes `solidity` can take, along with the config path of + * the entry, so that errors point at the right place. + */ +function collectSlangEntriesToDownload( + solidity: unknown, +): SlangEntryToDownload[] { + const entries: SlangEntryToDownload[] = []; + + function visit(entry: unknown, path: Array): void { + if ( + !isObject(entry) || + entry.type !== SLANG_COMPILER_TYPE || + (typeof entry.path === "string" && entry.path.length > 0) + ) { + return; + } + entries.push({ version: String(entry.version), path }); + } + + function visitVersionsConfig( + versionsConfig: unknown, + path: Array, + ): void { + if (!isObject(versionsConfig)) { + return; + } + if ("version" in versionsConfig) { + visit(versionsConfig, path); + return; + } + if (Array.isArray(versionsConfig.compilers)) { + versionsConfig.compilers.forEach((compiler, i) => + visit(compiler, [...path, "compilers", i]), + ); + } + if (isObject(versionsConfig.overrides)) { + for (const [key, override] of Object.entries(versionsConfig.overrides)) { + visit(override, [...path, "overrides", key]); + } + } + } + + if (isObject(solidity) && isObject(solidity.profiles)) { + for (const [name, profile] of Object.entries(solidity.profiles)) { + visitVersionsConfig(profile, ["solidity", "profiles", name]); + } + } else { + visitVersionsConfig(solidity, ["solidity"]); + } + + return entries; +} + +function isSolidityVersionSupportedBy( + version: string, + release: SlangRelease, +): boolean { + const parsed = parseVersion(version); + const min = parseVersion(release.minSolidity); + const max = parseVersion(release.maxSolidity); + + assertHardhatInvariant( + min !== undefined && max !== undefined, + `The slang release table has an invalid Solidity range: ${release.minSolidity} to ${release.maxSolidity}`, + ); + + return ( + parsed !== undefined && + greaterThanOrEqual(parsed, min) && + lowerThanOrEqual(parsed, max) + ); +} + export default async (): Promise> => ({ validateUserConfig, resolveUserConfig, @@ -263,7 +383,6 @@ async function augmentIfSlang< ...settings, // Defaults added here instead of in SlangCompiler.compile so this reaches // the solcInput and hence the build-id hash. - viaIR: settings.viaIR ?? false, optimizer: { ...optimizer, mode: optimizer.mode ?? DEFAULT_SLANG_OPTIMIZER_MODE, @@ -328,10 +447,9 @@ export async function validateResolvedConfig( return errors; } -function resolveSlangConfig(userConfig?: { - dangerouslyAllowSlangInProduction?: boolean; -}): SlangConfig { +function resolveSlangConfig(userConfig?: SlangUserConfig): SlangConfig { return { + version: userConfig?.version, dangerouslyAllowSlangInProduction: userConfig?.dangerouslyAllowSlangInProduction ?? false, }; diff --git a/packages/hardhat-slang/src/type-extensions.ts b/packages/hardhat-slang/src/type-extensions.ts index c84f0d9abe3..9041f0dc16f 100644 --- a/packages/hardhat-slang/src/type-extensions.ts +++ b/packages/hardhat-slang/src/type-extensions.ts @@ -43,6 +43,15 @@ declare module "hardhat/types/config" { } export interface SlangUserConfig { + /** + * The slang release to download and compile with. Every compiler entry + * with `type: "slang"` and no custom `path` uses this one binary, and its + * Solidity `version` must fall inside the release's supported range. + * + * Required when any such entry exists. + */ + version?: string; + /** * Allow compiler type `"slang"` in the production build profile. * By default, `"slang"` in production is rejected as a safeguard. @@ -51,6 +60,11 @@ declare module "hardhat/types/config" { } export interface SlangConfig { + /** + * The pinned slang release, or `undefined` when the user didn't set one + * (only valid when no `type: "slang"` entry needs a download). + */ + version: string | undefined; dangerouslyAllowSlangInProduction: boolean; } diff --git a/packages/hardhat-slang/test/config.ts b/packages/hardhat-slang/test/config.ts index 9bd7edfafa1..45b1064a5f6 100644 --- a/packages/hardhat-slang/test/config.ts +++ b/packages/hardhat-slang/test/config.ts @@ -1,7 +1,11 @@ /* eslint-disable @typescript-eslint/consistent-type-assertions -- test */ +import type { HardhatUserConfig } from "hardhat/types/config"; +import type { HardhatUserConfigValidationError } from "hardhat/types/hooks"; + import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { SLANG_RELEASES } from "../src/internal/constants.js"; import { resolveUserConfig, validateResolvedConfig, @@ -9,6 +13,37 @@ import { } from "../src/internal/hook-handlers/config.js"; import { SLANG_DEBUG_INFO_SELECTORS } from "../src/internal/slang-compiler.js"; +const PINNED_VERSION = "0.1.0-pre.2026-10-01"; +const { minSolidity: PINNED_MIN, maxSolidity: PINNED_MAX } = + SLANG_RELEASES[PINNED_VERSION]; + +/** + * A user config with a `slang` build profile holding the given compiler + * entries, pinned to the slang prerelease unless told otherwise. Pass + * `slangVersion: null` to leave `slang.version` out entirely. + */ +function slangProfileConfig( + compilers: any[], + options: { + overrides?: Record; + slangVersion?: string | null; + } = {}, +): HardhatUserConfig { + const { overrides, slangVersion = PINNED_VERSION } = options; + return { + solidity: { + profiles: { + slang: { compilers, overrides }, + }, + }, + ...(slangVersion === null ? {} : { slang: { version: slangVersion } }), + }; +} + +function rangeErrors(errors: HardhatUserConfigValidationError[]) { + return errors.filter((e) => e.message.includes("is not supported by slang")); +} + describe("hardhat-slang plugin config validation", () => { it("accepts valid config with dangerouslyAllowSlangInProduction", async () => { const errors = await validateUserConfig({ @@ -46,6 +81,44 @@ describe("hardhat-slang plugin config validation", () => { }); assert.ok(errors.length > 0, "Should have validation errors"); }); + + it("accepts a known slang.version", async () => { + const errors = await validateUserConfig({ + slang: { version: PINNED_VERSION }, + }); + assert.deepEqual(errors, []); + }); + + it("rejects an unknown slang.version, listing the known releases", async () => { + const errors = await validateUserConfig({ + slang: { version: "9.9.9" }, + }); + assert.equal(errors.length, 1, "exactly one error is expected"); + assert.deepEqual(errors[0].path, ["slang", "version"]); + assert.match(errors[0].message, /Unknown slang version "9\.9\.9"/); + assert.ok( + errors[0].message.includes(PINNED_VERSION), + `the error should list the known releases, got: ${errors[0].message}`, + ); + }); + + it("rejects a non-string slang.version", async () => { + const errors = await validateUserConfig({ + slang: { version: 1 as any }, + }); + assert.ok(errors.length > 0, "Should have validation errors"); + assert.deepEqual(errors[0].path, ["slang", "version"]); + }); + + it("does not report range errors for an unknown slang.version, only the unknown version", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.7.6", type: "slang" }], { + slangVersion: "9.9.9", + }), + ); + assert.equal(errors.length, 1, "only the unknown version is reported"); + assert.deepEqual(errors[0].path, ["slang", "version"]); + }); }); describe("hardhat-slang plugin config resolution", () => { @@ -75,6 +148,7 @@ describe("hardhat-slang plugin config resolution", () => { ); assert.equal(resolvedConfig.slang.dangerouslyAllowSlangInProduction, false); + assert.equal(resolvedConfig.slang.version, undefined); }); it("resolves dangerouslyAllowSlangInProduction from user config", async () => { @@ -94,6 +168,23 @@ describe("hardhat-slang plugin config resolution", () => { assert.equal(resolvedConfig.slang.dangerouslyAllowSlangInProduction, true); }); + it("resolves the pinned slang.version from user config", async () => { + const resolvedConfig = await resolveUserConfig( + { slang: { version: PINNED_VERSION } }, + undefined as any, + makeNext({ + default: { + isolated: false, + preferWasm: false, + compilers: [{ version: "0.8.34", settings: {} }], + overrides: {}, + }, + }), + ); + + assert.equal(resolvedConfig.slang.version, PINNED_VERSION); + }); + it("registers 'slang' as a compiler type", async () => { const resolvedConfig = await resolveUserConfig( {}, @@ -233,6 +324,45 @@ describe("hardhat-slang plugin config resolution", () => { } }); + it("augments every slang-typed compiler when several Solidity versions use slang", async () => { + const resolvedConfig = await resolveUserConfig( + { slang: { version: PINNED_VERSION } }, + undefined as any, + makeNext({ + slang: { + isolated: false, + preferWasm: false, + compilers: [ + { version: "0.8.34", type: "slang", settings: {} }, + { version: "0.8.20", type: "slang", settings: {} }, + { version: "0.7.6", settings: {} }, + ], + overrides: {}, + }, + }), + ); + + const [first, second, solc] = + resolvedConfig.solidity.profiles.slang.compilers; + for (const compiler of [first, second]) { + assert.equal(compiler.settings.optimizer.mode, "1"); + const wildcardSelectors = compiler.settings.outputSelection["*"][ + "*" + ] as string[]; + for (const selector of SLANG_DEBUG_INFO_SELECTORS) { + assert.ok( + wildcardSelectors.includes(selector), + `slang ${compiler.version} should include "${selector}"`, + ); + } + } + assert.deepEqual( + solc.settings, + {}, + "the solc entry in the same profile must be left untouched", + ); + }); + it("defaults the optimizer mode on slang-typed compilers in resolved config", async () => { const resolvedConfig = await resolveUserConfig( {}, @@ -329,7 +459,7 @@ describe("hardhat-slang plugin config resolution", () => { assert.equal(slangCompiler.settings.optimizer.mode, "1"); }); - it("defaults viaIR to false on slang-typed compilers, letting a user value win", async () => { + it("does not inject viaIR, and passes a user-set viaIR through untouched", async () => { const resolvedConfig = await resolveUserConfig( {}, undefined as any, @@ -350,7 +480,10 @@ describe("hardhat-slang plugin config resolution", () => { ); const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; - assert.equal(slangCompiler.settings.viaIR, false); + assert.ok( + !("viaIR" in slangCompiler.settings), + `viaIR must not be injected, got settings: ${JSON.stringify(slangCompiler.settings)}`, + ); const override = resolvedConfig.solidity.profiles.slang.overrides["contracts/ViaIR.sol"]; assert.equal(override.settings.viaIR, true); @@ -380,28 +513,22 @@ describe("hardhat-slang plugin config resolution", () => { const slangCompiler = resolvedConfig.solidity.profiles.slang.compilers[0]; // An arbitrary user solc setting survives config resolution untouched... assert.equal(slangCompiler.settings.evmVersion, "prague"); - // ...alongside a default the plugin fills in (the mode default has its own test). - assert.equal(slangCompiler.settings.viaIR, false); + // ...alongside a default the plugin fills in. + assert.equal(slangCompiler.settings.optimizer.mode, "1"); }); }); describe("hardhat-slang EVM version validation", () => { it("rejects type: 'slang' with pre-cancun evmVersion", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - settings: { evmVersion: "paris" }, - }, - ], - }, + const errors = await validateUserConfig( + slangProfileConfig([ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "paris" }, }, - }, - }); + ]), + ); assert.ok(errors.length > 0, "Should have validation errors"); assert.ok( errors.some((e) => e.message.includes("EVM versions")), @@ -410,130 +537,65 @@ describe("hardhat-slang EVM version validation", () => { }); it("rejects type: 'slang' with shanghai evmVersion", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - settings: { evmVersion: "shanghai" }, - }, - ], - }, + const errors = await validateUserConfig( + slangProfileConfig([ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "shanghai" }, }, - }, - }); + ]), + ); assert.ok(errors.length > 0, "Should have validation errors"); }); - it("accepts type: 'slang' with cancun evmVersion", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - settings: { evmVersion: "cancun" }, - }, - ], + for (const evmVersion of ["cancun", "prague", "osaka"]) { + it(`accepts type: 'slang' with ${evmVersion} evmVersion`, async () => { + const errors = await validateUserConfig( + slangProfileConfig([ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion }, }, - }, - }, - }); - assert.deepEqual(errors, []); - }); - - it("accepts type: 'slang' with prague evmVersion", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - settings: { evmVersion: "prague" }, - }, - ], - }, - }, - }, - }); - assert.deepEqual(errors, []); - }); - - it("accepts type: 'slang' with osaka evmVersion", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - settings: { evmVersion: "osaka" }, - }, - ], - }, - }, - }, + ]), + ); + assert.deepEqual(errors, []); }); - assert.deepEqual(errors, []); - }); + } it("accepts type: 'slang' without evmVersion", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [{ version: "0.8.34", type: "slang" }], - }, - }, - }, - }); + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.8.34", type: "slang" }]), + ); assert.deepEqual(errors, []); }); it("ignores evmVersion on non-slang compiler entries", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - settings: { evmVersion: "paris" }, - }, - ], - }, + const errors = await validateUserConfig( + slangProfileConfig([ + { + version: "0.8.34", + 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: { - slang: { - compilers: [{ version: "0.8.34" }], - overrides: { - "contracts/Old.sol": { - version: "0.8.34", - type: "slang", - settings: { evmVersion: "london" }, - }, - }, + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.8.34" }], { + overrides: { + "contracts/Old.sol": { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "london" }, }, }, - }, - }); + }), + ); assert.ok(errors.length > 0, "Should have validation errors"); assert.ok( errors[0].path.includes("overrides"), @@ -543,99 +605,183 @@ describe("hardhat-slang EVM version validation", () => { }); describe("hardhat-slang Solidity version validation", () => { - it("rejects type: 'slang' with unsupported Solidity version", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [{ version: "0.8.28", type: "slang" }], - }, - }, - }, - }); + it("rejects type: 'slang' entries when slang.version is missing", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.8.34", type: "slang" }], { + slangVersion: null, + }), + ); + assert.equal(errors.length, 1, "exactly one error is expected"); + assert.deepEqual(errors[0].path, ["slang", "version"]); + assert.match(errors[0].message, /slang\.version is required/); assert.ok( - errors.some((e) => e.message.includes("Slang only supports versions")), - `Expected Solidity version error, got: ${errors.map((e) => e.message).join(", ")}`, + errors[0].message.includes(PINNED_VERSION), + `the error should list the known releases, got: ${errors[0].message}`, ); }); - it("accepts type: 'slang' with supported Solidity version 0.8.34", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [{ version: "0.8.34", type: "slang" }], - }, + it("does not require slang.version when every slang entry has a custom path", async () => { + const errors = await validateUserConfig( + slangProfileConfig( + [{ version: "0.8.34", type: "slang", path: "/tmp/slang-custom" }], + { slangVersion: null }, + ), + ); + assert.deepEqual(errors, []); + }); + + it("does not require slang.version when no entry uses type: 'slang'", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.8.34" }], { slangVersion: null }), + ); + assert.deepEqual(errors, []); + }); + + it(`accepts the lowest Solidity version of the pinned prerelease, ${PINNED_MIN}`, async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: PINNED_MIN, type: "slang" }]), + ); + assert.deepEqual(errors, []); + }); + + it(`accepts the highest Solidity version of the pinned prerelease, ${PINNED_MAX}`, async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: PINNED_MAX, type: "slang" }]), + ); + assert.deepEqual(errors, []); + }); + + it("accepts a Solidity version in the middle of the range", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.8.20", type: "slang" }]), + ); + assert.deepEqual(errors, []); + }); + + it("rejects a Solidity version below the range, naming the release and its range", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.7.6", type: "slang" }]), + ); + assert.equal(errors.length, 1, "exactly one error is expected"); + assert.deepEqual(errors[0].path, [ + "solidity", + "profiles", + "slang", + "compilers", + 0, + "version", + ]); + assert.match( + errors[0].message, + new RegExp( + `0\\.7\\.6 is not supported by slang ${PINNED_VERSION}.*${PINNED_MIN}.*${PINNED_MAX}`, + ), + ); + }); + + it("rejects a Solidity version above the range", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.9.0", type: "slang" }]), + ); + assert.equal(rangeErrors(errors).length, 1); + }); + + it("reports the range error for each offending entry, by index", async () => { + const errors = await validateUserConfig( + slangProfileConfig([ + { version: "0.8.34", type: "slang" }, + { version: "0.7.6", type: "slang" }, + { version: "0.9.0", type: "slang" }, + ]), + ); + const paths = rangeErrors(errors).map((e) => e.path); + assert.deepEqual(paths, [ + ["solidity", "profiles", "slang", "compilers", 1, "version"], + ["solidity", "profiles", "slang", "compilers", 2, "version"], + ]); + }); + + it("checks the range of slang-typed overrides", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.8.34", type: "slang" }], { + overrides: { + "contracts/Old.sol": { version: "0.7.6", type: "slang" }, }, - }, - }); - const versionErrors = errors.filter((e) => - e.message.includes("Slang only supports versions"), + }), ); - assert.deepEqual(versionErrors, []); + const paths = rangeErrors(errors).map((e) => e.path); + assert.deepEqual(paths, [ + [ + "solidity", + "profiles", + "slang", + "overrides", + "contracts/Old.sol", + "version", + ], + ]); }); - it("accepts type: 'slang' with supported version and custom path", async () => { + it("checks the range of a single-version build profile", async () => { const errors = await validateUserConfig({ solidity: { profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - path: "/tmp/slang-custom", - }, - ], - }, + slang: { type: "slang", version: "0.7.6" }, }, }, + slang: { version: PINNED_VERSION }, }); - const versionErrors = errors.filter((e) => - e.message.includes("Slang only supports versions"), - ); - assert.deepEqual(versionErrors, []); + const paths = rangeErrors(errors).map((e) => e.path); + assert.deepEqual(paths, [["solidity", "profiles", "slang", "version"]]); }); - it("accepts type: 'slang' with unsupported version when path is set", async () => { + it("checks the range of top-level compilers outside build profiles", async () => { const errors = await validateUserConfig({ solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.35", - type: "slang", - path: "/tmp/slang-nightly", - }, - ], - }, - }, + compilers: [{ type: "slang", version: "0.7.6" }], }, + slang: { version: PINNED_VERSION }, }); - const versionErrors = errors.filter((e) => - e.message.includes("Slang only supports versions"), + const paths = rangeErrors(errors).map((e) => e.path); + assert.deepEqual(paths, [["solidity", "compilers", 0, "version"]]); + }); + + it("ignores the range of non-slang entries", async () => { + const errors = await validateUserConfig( + slangProfileConfig([ + { version: "0.8.34", type: "slang" }, + { version: "0.7.6" }, + { version: "0.9.0", type: "solc" }, + ]), ); - assert.deepEqual(versionErrors, []); + assert.deepEqual(errors, []); }); - it("rejects unsupported version when path is empty string", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [{ version: "0.8.35", type: "slang", path: "" }], - }, + it("accepts an out-of-range version when path is set", async () => { + const errors = await validateUserConfig( + slangProfileConfig([ + { + version: "0.7.6", + type: "slang", + path: "/tmp/slang-nightly", }, - }, - }); - const versionErrors = errors.filter((e) => - e.message.includes("Slang only supports versions"), + ]), ); - assert.ok( - versionErrors.length > 0, - "Expected version validation error for empty path", + assert.deepEqual(errors, []); + }); + + it("rejects an out-of-range version when path is an empty string", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.7.6", type: "slang", path: "" }]), + ); + assert.equal(rangeErrors(errors).length, 1); + }); + + it("rejects a Solidity version that isn't MAJOR.MINOR.PATCH", async () => { + const errors = await validateUserConfig( + slangProfileConfig([{ version: "0.8", type: "slang" }]), ); + assert.equal(rangeErrors(errors).length, 1); }); }); @@ -651,6 +797,7 @@ describe("hardhat-slang resolved config validation", () => { registeredCompilerTypes: ["solc", "slang"], }, slang: { + version: PINNED_VERSION, dangerouslyAllowSlangInProduction: opts?.dangerouslyAllowSlangInProduction ?? false, }, @@ -825,21 +972,15 @@ describe("hardhat-slang resolved config validation", () => { describe("hardhat-slang optimizer mode validation", () => { async function validateMode(mode: string) { - return await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - settings: { optimizer: { mode } }, - }, - ], - }, + return await validateUserConfig( + slangProfileConfig([ + { + version: "0.8.34", + type: "slang", + settings: { optimizer: { mode } }, }, - }, - }); + ]), + ); } for (const mode of ["1", "2", "3", "s", "z"]) { @@ -868,21 +1009,15 @@ describe("hardhat-slang optimizer mode validation", () => { }); it("leaves the other optimizer settings alone", async () => { - const errors = await validateUserConfig({ - solidity: { - profiles: { - slang: { - compilers: [ - { - version: "0.8.34", - type: "slang", - settings: { optimizer: { enabled: true, runs: 200 } }, - }, - ], - }, + const errors = await validateUserConfig( + slangProfileConfig([ + { + version: "0.8.34", + type: "slang", + settings: { optimizer: { enabled: true, runs: 200 } }, }, - }, - }); + ]), + ); assert.deepEqual(errors, []); }); diff --git a/packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts b/packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts index 53586a2ed44..03592ca55a0 100644 --- a/packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts +++ b/packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts @@ -14,6 +14,9 @@ const config: HardhatUserConfig = { }, }, }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, plugins: [HardhatSlangPlugin], }; diff --git a/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts b/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts index 53586a2ed44..03592ca55a0 100644 --- a/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts @@ -14,6 +14,9 @@ const config: HardhatUserConfig = { }, }, }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, plugins: [HardhatSlangPlugin], }; diff --git a/packages/hardhat-slang/test/integration.ts b/packages/hardhat-slang/test/integration.ts index 497debe6ffb..8e41ca94a47 100644 --- a/packages/hardhat-slang/test/integration.ts +++ b/packages/hardhat-slang/test/integration.ts @@ -20,6 +20,7 @@ describe("hardhat-slang integration", () => { it("resolves plugin config through the HRE", async () => { const hre = await createHre(); assert.equal(hre.config.slang.dangerouslyAllowSlangInProduction, false); + assert.equal(hre.config.slang.version, "0.1.0-pre.2026-10-01"); }); it("resolves plugin config with defaults when not specified", async () => { @@ -35,12 +36,54 @@ describe("hardhat-slang integration", () => { }, }, }, + slang: { version: "0.1.0-pre.2026-10-01" }, plugins: [(await import("../src/index.js")).default], }); assert.equal(hre.config.slang.dangerouslyAllowSlangInProduction, false); }); + it("coexists with hardhat-slang-solx in the same config", async () => { + const hre = await createHardhatRuntimeEnvironment({ + solidity: { + profiles: { + default: { + version: "0.8.34", + }, + slang: { + type: "slang", + version: "0.8.34", + }, + "slang-solx": { + type: "slang-solx", + version: "0.8.34", + }, + }, + }, + slang: { version: "0.1.0-pre.2026-10-01" }, + plugins: [ + (await import("../src/index.js")).default, + (await import("@nomicfoundation/hardhat-slang-solx")).default, + ], + }); + + assert.deepEqual( + [...hre.config.solidity.registeredCompilerTypes].sort(), + ["slang", "slang-solx", "solc"], + "both plugins register their compiler type", + ); + assert.equal(hre.config.slang.version, "0.1.0-pre.2026-10-01"); + assert.equal( + hre.config["slang-solx"].dangerouslyAllowSlangSolxInProduction, + false, + ); + assert.equal(hre.config.solidity.profiles.slang.compilers[0].type, "slang"); + assert.equal( + hre.config.solidity.profiles["slang-solx"].compilers[0].type, + "slang-solx", + ); + }); + it("default profile compilers use solc (no type or 'solc')", async () => { const hre = await createHre(); diff --git a/packages/hardhat-slang/test/optimizer-mode.ts b/packages/hardhat-slang/test/optimizer-mode.ts index 746d348fcef..1701834929f 100644 --- a/packages/hardhat-slang/test/optimizer-mode.ts +++ b/packages/hardhat-slang/test/optimizer-mode.ts @@ -47,8 +47,8 @@ describe("hardhat-slang optimizer mode reaches the solc input", () => { ); assert.equal( (solcInput.settings as { viaIR?: boolean }).viaIR, - false, - "the plugin's default viaIR:false should also reach the resolved solcInput", + undefined, + "the slang pipeline has no Yul step, so the plugin must not inject viaIR", ); }); }); diff --git a/packages/hardhat-slang/tsconfig.json b/packages/hardhat-slang/tsconfig.json index 4796cdc5061..dcc48c9cb68 100644 --- a/packages/hardhat-slang/tsconfig.json +++ b/packages/hardhat-slang/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../hardhat-node-test-reporter" }, + { + "path": "../hardhat-slang-solx" + }, { "path": "../hardhat-test-utils" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bff4dad562f..95ca56564ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1184,6 +1184,9 @@ importers: '@nomicfoundation/hardhat-node-test-reporter': specifier: workspace:^3.1.0 version: link:../hardhat-node-test-reporter + '@nomicfoundation/hardhat-slang-solx': + specifier: workspace:^ + version: link:../hardhat-slang-solx '@nomicfoundation/hardhat-test-utils': specifier: workspace:^ version: link:../hardhat-test-utils From 64bce2c7251c191335628254292b5838f835895f Mon Sep 17 00:00:00 2001 From: Marian Fechete Date: Thu, 1 Oct 2026 18:06:22 +0200 Subject: [PATCH 3/9] Download the pinned slang release from its release-table row --- .../hardhat-slang/src/internal/constants.ts | 5 - .../hardhat-slang/src/internal/downloader.ts | 19 +- .../src/internal/hook-handlers/solidity.ts | 106 ++++--- .../hardhat-slang/src/internal/platform.ts | 23 +- packages/hardhat-slang/test/downloader.ts | 162 ++++++++-- .../test/hook-handlers/solidity.ts | 290 +++++++++++------- .../hardhat-slang/test/mirror-availability.ts | 35 ++- packages/hardhat-slang/test/platform.ts | 32 +- 8 files changed, 447 insertions(+), 225 deletions(-) diff --git a/packages/hardhat-slang/src/internal/constants.ts b/packages/hardhat-slang/src/internal/constants.ts index 6b1e5cb29db..53c896779f9 100644 --- a/packages/hardhat-slang/src/internal/constants.ts +++ b/packages/hardhat-slang/src/internal/constants.ts @@ -36,11 +36,6 @@ export const SUPPORTED_SLANG_OPTIMIZER_MODES: readonly string[] = [ */ export const DEFAULT_SLANG_OPTIMIZER_MODE = "1"; -/** Maps Solidity versions to the slang version that embeds them. */ -export const SOLIDITY_TO_SOLX_VERSION_MAP: Record = { - "0.8.34": "0.1.8", -}; - /** * A slang release the plugin knows how to download and drive. slang compiles * a range of Solidity versions with one binary, so each release maps to an diff --git a/packages/hardhat-slang/src/internal/downloader.ts b/packages/hardhat-slang/src/internal/downloader.ts index 4c8938d99b1..4d57de28d99 100644 --- a/packages/hardhat-slang/src/internal/downloader.ts +++ b/packages/hardhat-slang/src/internal/downloader.ts @@ -1,3 +1,4 @@ +import type { SlangRelease } from "./constants.js"; import type { PrefixedHexString } from "@nomicfoundation/hardhat-utils/hex"; import type { Dispatcher } from "@nomicfoundation/hardhat-utils/request"; @@ -41,14 +42,15 @@ const SHA256_HEX_DIGEST_LENGTH = 64; const DOWNLOAD_RETRY_DELAY_MS = 2000; /** - * Returns the deterministic path where a slang binary for the given version + * Returns the deterministic path where the binary of the given slang release * would be cached. This is a pure function β€” it does not check whether the * binary exists on disk. */ export async function getSlangBinaryPath( slangVersion: string, + release: SlangRelease, ): Promise { - const assetName = getSlangAssetName(slangVersion); + const assetName = getSlangAssetName(slangVersion, release); const globalCacheDir = await getCacheDir(); return path.join( globalCacheDir, @@ -72,20 +74,22 @@ export interface DownloadSlangOptions { } /** - * Downloads the slang binary for the given version if not already cached. + * Downloads the binary of the given slang release if not already cached. * Returns the path to the binary on disk. * - * @param slangVersion - The slang version to download (e.g. "0.1.3") + * @param slangVersion - The slang version to download (e.g. "0.1.0-pre.2026-10-01") + * @param release - The release table row for that version * @param onBinaryDownloadStart - A callback invoked once the compiler download is about to start * @param options - See {@link DownloadSlangOptions}. */ export async function downloadSlang( slangVersion: string, + release: SlangRelease, onBinaryDownloadStart: () => void, options: DownloadSlangOptions = {}, ): Promise { const { dispatcher, retryDelayMs = DOWNLOAD_RETRY_DELAY_MS } = options; - const binaryPath = await getSlangBinaryPath(slangVersion); + const binaryPath = await getSlangBinaryPath(slangVersion, release); // Return cached binary if it already exists if (await exists(binaryPath)) { @@ -97,8 +101,9 @@ export async function downloadSlang( const mutex = new MultiProcessMutex( path.join(globalCacheDir, `slang-download-${slangVersion}`), ); - const assetName = getSlangAssetName(slangVersion); - const url = `${SLANG_RELEASES_BASE_URL}/${assetName}`; + const assetName = getSlangAssetName(slangVersion, release); + const baseUrl = release.assetOverride?.baseUrl ?? SLANG_RELEASES_BASE_URL; + const url = `${baseUrl}/${assetName}`; // The checksum is required, we fail immediately if we can't get it. const expectedChecksum = await downloadExpectedChecksum( diff --git a/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts b/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts index d36ca9ac6c5..0834eaf8496 100644 --- a/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts +++ b/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts @@ -1,3 +1,5 @@ +import type { SlangRelease } from "../constants.js"; +import type { HardhatConfig } from "hardhat/types/config"; import type { SolidityHooks } from "hardhat/types/hooks"; import { execFile } from "node:child_process"; @@ -10,10 +12,7 @@ import { import { createDebug } from "@nomicfoundation/hardhat-utils/debug"; import { exists } from "@nomicfoundation/hardhat-utils/fs"; -import { - SOLIDITY_TO_SOLX_VERSION_MAP, - SLANG_COMPILER_TYPE, -} from "../constants.js"; +import { SLANG_COMPILER_TYPE, SLANG_RELEASES } from "../constants.js"; import { downloadSlang, getSlangBinaryPath } from "../downloader.js"; import { SlangCompiler } from "../slang-compiler.js"; @@ -39,49 +38,68 @@ async function getSlangVersionFromBinary(binaryPath: string): Promise { return parseSlangVersion(stdout); } -export default async (): Promise> => ({ - downloadCompilers: async (_context, compilerConfigs, quiet) => { - const slangConfigs = compilerConfigs.filter( - (c) => c.type === SLANG_COMPILER_TYPE, - ); +/** + * Returns the slang release pinned in the config. Config validation already + * guarantees it is set and known whenever a `type: "slang"` entry needs a + * download, so both checks are invariants here. + */ +function getPinnedRelease(config: HardhatConfig): { + version: string; + release: SlangRelease; +} { + const version = config.slang.version; + assertHardhatInvariant( + version !== undefined, + `slang.version is not set β€” this should have been caught by config validation`, + ); - if (slangConfigs.length === 0) { - return; - } + const release = SLANG_RELEASES[version]; + assertHardhatInvariant( + release !== undefined, + `Unknown slang version ${version} β€” this should have been caught by config validation`, + ); + + return { version, release }; +} - // Collect unique slang versions to download (skip configs with custom path) - const slangVersions = new Set(); - for (const config of slangConfigs) { - if (config.path !== undefined) { +export default async (): Promise> => ({ + downloadCompilers: async (context, compilerConfigs, quiet) => { + // Every type: "slang" entry without a custom path shares the single + // pinned release, whatever its Solidity version, so there is at most one + // binary to download. + const needsDownload = compilerConfigs.some((c) => { + if (c.type !== SLANG_COMPILER_TYPE) { + return false; + } + if (c.path !== undefined) { log( - `Skipping download for Solidity ${config.version}: custom path provided`, + `Skipping download for Solidity ${c.version}: custom path provided`, ); - continue; - } - const slangVersion = SOLIDITY_TO_SOLX_VERSION_MAP[config.version]; - if (slangVersion !== undefined) { - slangVersions.add(slangVersion); + return false; } + return true; + }); + + if (!needsDownload) { + return; } - await Promise.all( - [...slangVersions].map(async (slangVersion) => { - const binaryPath = await getSlangBinaryPath(slangVersion); - if (await exists(binaryPath)) { - log(`slang ${slangVersion} already cached at ${binaryPath}`); - return; - } - - const slangPath = await downloadSlang(slangVersion, () => { - if (quiet) { - return; - } - - console.log(`Downloading slang ${slangVersion}`); - }); - log(`Downloaded slang ${slangVersion} to ${slangPath}`); - }), - ); + const { version: slangVersion, release } = getPinnedRelease(context.config); + + const binaryPath = await getSlangBinaryPath(slangVersion, release); + if (await exists(binaryPath)) { + log(`slang ${slangVersion} already cached at ${binaryPath}`); + return; + } + + const slangPath = await downloadSlang(slangVersion, release, () => { + if (quiet) { + return; + } + + console.log(`Downloading slang ${slangVersion}`); + }); + log(`Downloaded slang ${slangVersion} to ${slangPath}`); }, getCompiler: async (context, compilerConfig, next) => { @@ -109,13 +127,9 @@ export default async (): Promise> => ({ return new SlangCompiler(customSlangVersion, compilerConfig.path); } - const slangVersion = SOLIDITY_TO_SOLX_VERSION_MAP[compilerConfig.version]; - assertHardhatInvariant( - slangVersion !== undefined, - `No slang version mapping for Solidity ${compilerConfig.version} β€” this should have been caught by config validation`, - ); + const { version: slangVersion, release } = getPinnedRelease(context.config); - const binaryPath = await getSlangBinaryPath(slangVersion); + const binaryPath = await getSlangBinaryPath(slangVersion, release); assertHardhatInvariant( await exists(binaryPath), diff --git a/packages/hardhat-slang/src/internal/platform.ts b/packages/hardhat-slang/src/internal/platform.ts index 5e914fbfb69..fdc4e36502a 100644 --- a/packages/hardhat-slang/src/internal/platform.ts +++ b/packages/hardhat-slang/src/internal/platform.ts @@ -1,12 +1,16 @@ +import type { SlangRelease } from "./constants.js"; + import os from "node:os"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; /** * Returns the platform-specific base name for the slang binary (without version suffix). - * The full asset name is `${baseName}-v${version}` (or `.exe` on Windows). + * The full asset name is `${baseName}-v${version}` (or `.exe` on Windows), + * unless the release overrides the suffix. * - * Actual GitHub release assets (e.g., for v0.1.4): + * The slang pipeline ships as the `solx` binary built with its Slang front + * end, so the assets keep the `solx-` prefix for now: * solx-linux-amd64-gnu-v0.1.4 * solx-linux-arm64-gnu-v0.1.4 * solx-macosx-v0.1.4 (universal binary) @@ -31,10 +35,19 @@ export function getSlangBinaryBaseName(): string { ); } -export function getSlangAssetName(version: string): string { +/** + * Returns the release asset name for the given slang release on the current + * platform: `${baseName}-v${version}`, or the release's own suffix when it + * isn't published under the standard naming. + */ +export function getSlangAssetName( + version: string, + release: SlangRelease, +): string { const baseName = getSlangBinaryBaseName(); + const suffix = release.assetOverride?.assetSuffix ?? `v${version}`; if (process.platform === "win32") { - return `${baseName}-v${version}.exe`; + return `${baseName}-${suffix}.exe`; } - return `${baseName}-v${version}`; + return `${baseName}-${suffix}`; } diff --git a/packages/hardhat-slang/test/downloader.ts b/packages/hardhat-slang/test/downloader.ts index a10a98677e4..ed260c4a71d 100644 --- a/packages/hardhat-slang/test/downloader.ts +++ b/packages/hardhat-slang/test/downloader.ts @@ -1,3 +1,4 @@ +import type { SlangRelease } from "../src/internal/constants.js"; import type { Interceptable, TestDispatcher, @@ -23,7 +24,10 @@ import { import { bytesToHexString } from "@nomicfoundation/hardhat-utils/hex"; import { getTestDispatcher } from "@nomicfoundation/hardhat-utils/request"; -import { SLANG_RELEASES_BASE_URL } from "../src/internal/constants.js"; +import { + SLANG_RELEASES, + SLANG_RELEASES_BASE_URL, +} from "../src/internal/constants.js"; import { downloadSlang, getSlangBinaryPath, @@ -32,6 +36,16 @@ import { getSlangAssetName } from "../src/internal/platform.js"; const TEST_SLANG_VERSION = "0.0.1-test"; +// A release served by the mirror under the standard `v${version}` naming. +const TEST_RELEASE: SlangRelease = { + minSolidity: "0.8.0", + maxSolidity: "0.8.36", + extraArgs: [], +}; + +const PINNED_VERSION = "0.1.0-pre.2026-10-01"; +const PINNED_RELEASE = SLANG_RELEASES[PINNED_VERSION]; + const BINARY_CONTENTS = "#!/bin/sh\necho slang 0.0.1-test\n"; const RETRY_COUNT = 3; @@ -50,9 +64,10 @@ describe("hardhat-slang downloader", () => { interceptable: Interceptable, body: string, times = 1, + asset = assetName, ): void { interceptable - .intercept({ path: `/${assetName}.sha256`, method: "GET" }) + .intercept({ path: `/${asset}.sha256`, method: "GET" }) .reply(200, body) .times(times); } @@ -61,9 +76,10 @@ describe("hardhat-slang downloader", () => { interceptable: Interceptable, body: string, times = 1, + asset = assetName, ): void { interceptable - .intercept({ path: `/${assetName}`, method: "GET" }) + .intercept({ path: `/${asset}`, method: "GET" }) .reply(200, body) .times(times); } @@ -72,7 +88,7 @@ describe("hardhat-slang downloader", () => { tmpDir = await makeWorkspaceTmpDir("slang-downloader"); setMockCacheDir(tmpDir); - assetName = getSlangAssetName(TEST_SLANG_VERSION); + assetName = getSlangAssetName(TEST_SLANG_VERSION, TEST_RELEASE); expectedDigest = bytesToHexString( await sha256(Buffer.from(BINARY_CONTENTS)), ).slice(2); @@ -93,15 +109,38 @@ describe("hardhat-slang downloader", () => { await safeRemoveTmpDir(tmpDir); }); + it("caches each slang release under its own versioned directory", async () => { + const binaryPath = await getSlangBinaryPath( + TEST_SLANG_VERSION, + TEST_RELEASE, + ); + + assert.equal( + binaryPath, + path.join( + tmpDir, + "compilers-v3", + `slang-v${TEST_SLANG_VERSION}`, + assetName, + ), + ); + }); + it("should successfully verify the download against the sidecar and keep the binary", async () => { interceptChecksum(mockedInterceptor, expectedDigest); interceptBinary(mockedInterceptor, BINARY_CONTENTS); - const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { - dispatcher: mockAgent, - }); + const binaryPath = await downloadSlang( + TEST_SLANG_VERSION, + TEST_RELEASE, + noop, + { dispatcher: mockAgent }, + ); - assert.equal(binaryPath, await getSlangBinaryPath(TEST_SLANG_VERSION)); + assert.equal( + binaryPath, + await getSlangBinaryPath(TEST_SLANG_VERSION, TEST_RELEASE), + ); assert.ok(await exists(binaryPath), "the binary should have been kept"); if (process.platform === "win32") { @@ -116,13 +155,59 @@ describe("hardhat-slang downloader", () => { ); }); + it("downloads the pinned prerelease from its GitHub prerelease URL under its own asset name", async () => { + const { baseUrl, assetSuffix } = PINNED_RELEASE.assetOverride ?? {}; + assert.ok( + baseUrl !== undefined && assetSuffix !== undefined, + "the prerelease row must carry an asset override", + ); + const pinnedAsset = getSlangAssetName(PINNED_VERSION, PINNED_RELEASE); + assert.ok( + pinnedAsset.includes(assetSuffix), + `the prerelease asset should use the override suffix, got ${pinnedAsset}`, + ); + + // The GitHub origin, not the mirror, must receive both requests. + const githubInterceptor = mockAgent.get(new URL(baseUrl).origin); + const assetPath = `${new URL(baseUrl).pathname}/${pinnedAsset}`; + githubInterceptor + .intercept({ path: `${assetPath}.sha256`, method: "GET" }) + .reply(200, expectedDigest); + githubInterceptor + .intercept({ path: assetPath, method: "GET" }) + .reply(200, BINARY_CONTENTS); + + const binaryPath = await downloadSlang( + PINNED_VERSION, + PINNED_RELEASE, + noop, + { + dispatcher: mockAgent, + }, + ); + + assert.equal( + binaryPath, + path.join( + tmpDir, + "compilers-v3", + `slang-v${PINNED_VERSION}`, + pinnedAsset, + ), + ); + assert.ok(await exists(binaryPath), "the binary should have been kept"); + }); + it("should accept a sha256sum-style sidecar, with the filename after the digest", async () => { interceptChecksum(mockedInterceptor, `${expectedDigest} ${assetName}`); interceptBinary(mockedInterceptor, BINARY_CONTENTS); - const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { - dispatcher: mockAgent, - }); + const binaryPath = await downloadSlang( + TEST_SLANG_VERSION, + TEST_RELEASE, + noop, + { dispatcher: mockAgent }, + ); assert.ok( await exists(binaryPath), @@ -134,9 +219,12 @@ describe("hardhat-slang downloader", () => { interceptChecksum(mockedInterceptor, expectedDigest.toUpperCase()); interceptBinary(mockedInterceptor, BINARY_CONTENTS); - const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { - dispatcher: mockAgent, - }); + const binaryPath = await downloadSlang( + TEST_SLANG_VERSION, + TEST_RELEASE, + noop, + { dispatcher: mockAgent }, + ); assert.ok( await exists(binaryPath), @@ -150,7 +238,9 @@ describe("hardhat-slang downloader", () => { .replyWithError(new Error("socket hang up")); await assertRejectsWithHardhatError( - downloadSlang(TEST_SLANG_VERSION, noop, { dispatcher: mockAgent }), + downloadSlang(TEST_SLANG_VERSION, TEST_RELEASE, noop, { + dispatcher: mockAgent, + }), HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, { version: TEST_SLANG_VERSION, @@ -166,7 +256,9 @@ describe("hardhat-slang downloader", () => { .reply(404, "Not Found"); await assertRejectsWithHardhatError( - downloadSlang(TEST_SLANG_VERSION, noop, { dispatcher: mockAgent }), + downloadSlang(TEST_SLANG_VERSION, TEST_RELEASE, noop, { + dispatcher: mockAgent, + }), HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, { version: TEST_SLANG_VERSION, @@ -176,7 +268,7 @@ describe("hardhat-slang downloader", () => { ); assert.equal( - await exists(await getSlangBinaryPath(TEST_SLANG_VERSION)), + await exists(await getSlangBinaryPath(TEST_SLANG_VERSION, TEST_RELEASE)), false, "no binary should be left behind", ); @@ -187,7 +279,9 @@ describe("hardhat-slang downloader", () => { interceptChecksum(mockedInterceptor, "Not Found"); await assertRejectsWithHardhatError( - downloadSlang(TEST_SLANG_VERSION, noop, { dispatcher: mockAgent }), + downloadSlang(TEST_SLANG_VERSION, TEST_RELEASE, noop, { + dispatcher: mockAgent, + }), HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.CHECKSUM_DOWNLOAD_FAILED, { version: TEST_SLANG_VERSION, @@ -197,7 +291,7 @@ describe("hardhat-slang downloader", () => { ); assert.equal( - await exists(await getSlangBinaryPath(TEST_SLANG_VERSION)), + await exists(await getSlangBinaryPath(TEST_SLANG_VERSION, TEST_RELEASE)), false, "no binary should be left behind", ); @@ -212,7 +306,7 @@ describe("hardhat-slang downloader", () => { ); await assertRejectsWithHardhatError( - downloadSlang(TEST_SLANG_VERSION, noop, { + downloadSlang(TEST_SLANG_VERSION, TEST_RELEASE, noop, { dispatcher: mockAgent, retryDelayMs: 0, }), @@ -221,7 +315,7 @@ describe("hardhat-slang downloader", () => { ); assert.equal( - await exists(await getSlangBinaryPath(TEST_SLANG_VERSION)), + await exists(await getSlangBinaryPath(TEST_SLANG_VERSION, TEST_RELEASE)), false, "a binary that failed verification must not be left on disk", ); @@ -232,10 +326,12 @@ describe("hardhat-slang downloader", () => { interceptBinary(mockedInterceptor, "truncated"); interceptBinary(mockedInterceptor, BINARY_CONTENTS); - const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { - dispatcher: mockAgent, - retryDelayMs: 0, - }); + const binaryPath = await downloadSlang( + TEST_SLANG_VERSION, + TEST_RELEASE, + noop, + { dispatcher: mockAgent, retryDelayMs: 0 }, + ); assert.ok( await exists(binaryPath), @@ -247,9 +343,12 @@ describe("hardhat-slang downloader", () => { interceptChecksum(mockedInterceptor, expectedDigest); interceptBinary(mockedInterceptor, BINARY_CONTENTS); - const binaryPath = await downloadSlang(TEST_SLANG_VERSION, noop, { - dispatcher: mockAgent, - }); + const binaryPath = await downloadSlang( + TEST_SLANG_VERSION, + TEST_RELEASE, + noop, + { dispatcher: mockAgent }, + ); assert.ok( !(await exists(`${binaryPath}.tmp`)), @@ -259,13 +358,16 @@ describe("hardhat-slang downloader", () => { it("returns a cached binary without making any request", async () => { // No interceptors at all: reaching the network would throw. - const binaryPath = await getSlangBinaryPath(TEST_SLANG_VERSION); + const binaryPath = await getSlangBinaryPath( + TEST_SLANG_VERSION, + TEST_RELEASE, + ); await ensureDir(path.dirname(binaryPath)); await writeFile(binaryPath, BINARY_CONTENTS); assert.equal( - await downloadSlang(TEST_SLANG_VERSION, noop, { + await downloadSlang(TEST_SLANG_VERSION, TEST_RELEASE, noop, { dispatcher: mockAgent, }), binaryPath, diff --git a/packages/hardhat-slang/test/hook-handlers/solidity.ts b/packages/hardhat-slang/test/hook-handlers/solidity.ts index 41485680e76..370b09bd0b7 100644 --- a/packages/hardhat-slang/test/hook-handlers/solidity.ts +++ b/packages/hardhat-slang/test/hook-handlers/solidity.ts @@ -1,14 +1,32 @@ -/* 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 { parseSlangVersion } from "../../src/internal/hook-handlers/solidity.js"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { + assertRejectsWithHardhatError, + makeWorkspaceTmpDir, + safeRemoveTmpDir, +} from "@nomicfoundation/hardhat-test-utils"; +import { ensureDir, exists } from "@nomicfoundation/hardhat-utils/fs"; +import { + resetMockCacheDir, + setMockCacheDir, +} from "@nomicfoundation/hardhat-utils/global-dir"; + +import { SLANG_RELEASES } from "../../src/internal/constants.js"; +import { getSlangBinaryPath } from "../../src/internal/downloader.js"; +import solidityHookHandlers, { + parseSlangVersion, +} from "../../src/internal/hook-handlers/solidity.js"; + +const PINNED_VERSION = "0.1.0-pre.2026-10-01"; +const PINNED_RELEASE = SLANG_RELEASES[PINNED_VERSION]; // Helper to create a compiler config function createSolidityCompilerConfig( @@ -24,6 +42,11 @@ function createSolidityCompilerConfig( }; } +// A hook context whose config pins the given slang release, or none for null +function createContext(slangVersion: string | null = PINNED_VERSION): any { + return { config: { slang: { version: slangVersion ?? undefined } } }; +} + // A mock "next" function for getCompiler function createGetCompilerMockNext() { let called = false; @@ -57,7 +80,7 @@ describe("parseSlangVersion", () => { it("parses version from stable release output", () => { assert.equal( parseSlangVersion( - "slang, LLVM-based Solidity compiler for the EVM v0.1.3, LLVM revision: v1.0.2, LLVM build: a33d492", + "solx, LLVM-based Solidity compiler for the EVM v0.1.3, LLVM revision: v1.0.2, LLVM build: a33d492", ), "0.1.3", ); @@ -66,26 +89,54 @@ describe("parseSlangVersion", () => { it("parses version from nightly build output", () => { assert.equal( parseSlangVersion( - "slang v0.1.4, LLVM-based Solidity compiler for the EVM, Front end: solc, LLVM build: 12f24e07", + "solx v0.1.4, LLVM-based Solidity compiler for the EVM, Front end: solc, LLVM build: 12f24e07", ), "0.1.4", ); }); + it("parses the prerelease build's banner, which names the Slang front end", () => { + assert.equal( + parseSlangVersion( + "solx v0.1.8, LLVM-based Solidity compiler for the EVM, Front end: Slang, LLVM build: e7c67b95\nVersion: 0.8.37", + ), + "0.1.8", + ); + }); + it("parses pre-release version", () => { assert.equal( - parseSlangVersion("slang v0.2.0-alpha.1, LLVM-based Solidity compiler"), + parseSlangVersion("solx v0.2.0-alpha.1, LLVM-based Solidity compiler"), "0.2.0-alpha.1", ); }); }); describe("hardhat-slang solidity hook handler", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await makeWorkspaceTmpDir("slang-solidity-hooks"); + setMockCacheDir(tmpDir); + }); + + afterEach(async () => { + resetMockCacheDir(); + await safeRemoveTmpDir(tmpDir); + }); + + // Puts a fake binary where the downloader would cache the pinned prerelease, so + // the hooks take their cached path and never reach the network. + async function cacheFakePinnedBinary(): Promise { + const binaryPath = await getSlangBinaryPath(PINNED_VERSION, PINNED_RELEASE); + await ensureDir(path.dirname(binaryPath)); + await writeFile(binaryPath, "#!/bin/sh\necho fake slang\n"); + return binaryPath; + } + 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(); + const hooks = await solidityHookHandlers(); assert.ok( hooks.downloadCompilers !== undefined, @@ -94,66 +145,83 @@ describe("hardhat-slang solidity hook handler", () => { }); it("does nothing when no slang-typed compilers present", async () => { - const hookHandlerModule = - await import("../../src/internal/hook-handlers/solidity.js"); - const hooks = await hookHandlerModule.default(); + const hooks = await solidityHookHandlers(); - const context = { config: {} } as any; - - // All configs are solc (no type or type undefined) + // All configs are solc (no type or type undefined), and nothing is + // pinned: the hook must not even look at slang.version. 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); + await hooks.downloadCompilers!(createContext(null), configs, true); - // Paths should remain undefined (no download triggered, no mutation) - assert.equal(configs[0].path, undefined); - assert.equal(configs[1].path, undefined); + assert.equal( + await exists(await getSlangBinaryPath(PINNED_VERSION, PINNED_RELEASE)), + false, + "nothing should have been downloaded", + ); }); - it("does not mutate compiler config paths", async () => { - const hookHandlerModule = - await import("../../src/internal/hook-handlers/solidity.js"); - const hooks = await hookHandlerModule.default(); + it("skips download when every slang config has a custom path", async () => { + const hooks = await solidityHookHandlers(); - const context = { config: {} } as any; + const configs: SolidityCompilerConfig[] = [ + createSolidityCompilerConfig({ + type: "slang", + version: "0.8.34", + path: "/custom/path/to/slang", + }), + ]; + + // Not pinned either: a custom path never needs the release table. + await hooks.downloadCompilers!(createContext(null), configs, true); + + assert.equal( + await exists(await getSlangBinaryPath(PINNED_VERSION, PINNED_RELEASE)), + false, + "nothing should have been downloaded", + ); + }); + + it("uses the cached binary of the pinned release for every slang Solidity version", async () => { + const hooks = await solidityHookHandlers(); + await cacheFakePinnedBinary(); const configs: SolidityCompilerConfig[] = [ createSolidityCompilerConfig({ type: "slang", version: "0.8.34" }), + createSolidityCompilerConfig({ type: "slang", version: "0.8.20" }), ]; - // 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 downloadSlang, but for now just - // check the path isn't set before the download attempt. - const originalPath = configs[0].path; + // Two Solidity versions, one pinned release: the hook must resolve + // without any download attempt (there is no network in this test). + await hooks.downloadCompilers!(createContext(), configs, true); - try { - await hooks.downloadCompilers!(context, configs, true); - } catch { - // Expected β€” download fails in test environment - } + assert.equal(configs[0].path, undefined, "paths must not be mutated"); + assert.equal(configs[1].path, undefined, "paths must not be mutated"); + }); - assert.equal( - configs[0].path, - originalPath, - "compiler config path should not be mutated", + it("throws an invariant error when slang.version isn't pinned but a download is needed", async () => { + const hooks = await solidityHookHandlers(); + + const configs: SolidityCompilerConfig[] = [ + createSolidityCompilerConfig({ type: "slang", version: "0.8.34" }), + ]; + + await assertRejectsWithHardhatError( + hooks.downloadCompilers!(createContext(null), configs, true), + HardhatError.ERRORS.CORE.INTERNAL.ASSERTION_ERROR, + { + message: + "slang.version is not set β€” this should have been caught by config validation", + }, ); }); }); 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(); + const hooks = await solidityHookHandlers(); assert.ok( hooks.getCompiler !== undefined, @@ -162,16 +230,13 @@ describe("hardhat-slang solidity hook handler", () => { }); it("passes through to next for non-slang compiler configs", async () => { - const hookHandlerModule = - await import("../../src/internal/hook-handlers/solidity.js"); - const hooks = await hookHandlerModule.default(); + const hooks = await solidityHookHandlers(); - const context = { config: {} } as any; const compilerConfig = createSolidityCompilerConfig({ type: "solc" }); const mockNext = createGetCompilerMockNext(); const result = await hooks.getCompiler!( - context, + createContext(null), compilerConfig, mockNext.next, ); @@ -181,16 +246,13 @@ describe("hardhat-slang solidity hook handler", () => { }); 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 hooks = await solidityHookHandlers(); - const context = { config: {} } as any; const compilerConfig = createSolidityCompilerConfig({ type: undefined }); const mockNext = createGetCompilerMockNext(); const result = await hooks.getCompiler!( - context, + createContext(null), compilerConfig, mockNext.next, ); @@ -199,36 +261,72 @@ describe("hardhat-slang solidity hook handler", () => { assert.equal(result, mockNext.compiler); }); - it("throws invariant error for unsupported slang version", async () => { - const { HardhatError } = await import("@nomicfoundation/hardhat-errors"); - const hookHandlerModule = - await import("../../src/internal/hook-handlers/solidity.js"); - const hooks = await hookHandlerModule.default(); + it("returns a SlangCompiler for the pinned release when its binary is cached", async () => { + const hooks = await solidityHookHandlers(); + const binaryPath = await cacheFakePinnedBinary(); - const context = { config: {} } as any; const compilerConfig = createSolidityCompilerConfig({ type: "slang", - version: "0.8.99", + version: "0.8.34", + }); + const mockNext = createGetCompilerMockNext(); + + const compiler = await hooks.getCompiler!( + createContext(), + compilerConfig, + mockNext.next, + ); + + assert.ok( + !mockNext.wasCalled(), + "next should NOT have been called for slang type", + ); + assert.equal(compiler.compilerPath, binaryPath); + assert.equal(compiler.version, PINNED_VERSION); + assert.equal(compiler.longVersion, `${PINNED_VERSION}+slang`); + assert.equal(compiler.isSolcJs, false); + }); + + it("throws an invariant error when the pinned binary isn't cached", async () => { + const hooks = await solidityHookHandlers(); + + const compilerConfig = createSolidityCompilerConfig({ + type: "slang", + version: "0.8.34", + }); + const mockNext = createGetCompilerMockNext(); + + await assertRejectsWithHardhatError( + hooks.getCompiler!(createContext(), compilerConfig, mockNext.next), + HardhatError.ERRORS.CORE.INTERNAL.ASSERTION_ERROR, + { + message: `slang binary not found at ${await getSlangBinaryPath(PINNED_VERSION, PINNED_RELEASE)} β€” downloadCompilers should have been called first`, + }, + ); + }); + + it("throws an invariant error when slang.version isn't pinned", async () => { + const hooks = await solidityHookHandlers(); + + const compilerConfig = createSolidityCompilerConfig({ + type: "slang", + version: "0.8.34", }); const mockNext = createGetCompilerMockNext(); await assertRejectsWithHardhatError( - hooks.getCompiler!(context, compilerConfig, mockNext.next), + hooks.getCompiler!(createContext(null), compilerConfig, mockNext.next), HardhatError.ERRORS.CORE.INTERNAL.ASSERTION_ERROR, { message: - "No slang version mapping for Solidity 0.8.99 β€” this should have been caught by config validation", + "slang.version is not set β€” 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 hooks = await solidityHookHandlers(); - const context = { config: {} } as any; const compilerConfig = createSolidityCompilerConfig({ type: "slang", version: "0.8.34", @@ -237,7 +335,7 @@ describe("hardhat-slang solidity hook handler", () => { const mockNext = createGetCompilerMockNext(); await assertRejectsWithHardhatError( - hooks.getCompiler!(context, compilerConfig, mockNext.next), + hooks.getCompiler!(createContext(null), compilerConfig, mockNext.next), HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.BINARY_NOT_FOUND, { path: "/nonexistent/path/to/slang", @@ -246,21 +344,18 @@ describe("hardhat-slang solidity hook handler", () => { }); it("returns SlangCompiler with version from binary when path is provided", async () => { - const { getSlangBinaryPath } = - await import("../../src/internal/downloader.js"); - const { exists } = await import("@nomicfoundation/hardhat-utils/fs"); - - // Use the cached slang binary if available, skip otherwise - const cachedPath = await getSlangBinaryPath("0.1.4"); + // Use the real prerelease binary if it is in the global cache, skip otherwise + resetMockCacheDir(); + const cachedPath = await getSlangBinaryPath( + PINNED_VERSION, + PINNED_RELEASE, + ); if (!(await exists(cachedPath))) { return; } - const hookHandlerModule = - await import("../../src/internal/hook-handlers/solidity.js"); - const hooks = await hookHandlerModule.default(); + const hooks = await solidityHookHandlers(); - const context = { config: {} } as any; const compilerConfig = createSolidityCompilerConfig({ type: "slang", version: "0.8.34", @@ -269,7 +364,7 @@ describe("hardhat-slang solidity hook handler", () => { const mockNext = createGetCompilerMockNext(); const compiler = await hooks.getCompiler!( - context, + createContext(null), compilerConfig, mockNext.next, ); @@ -280,29 +375,8 @@ describe("hardhat-slang solidity hook handler", () => { ); assert.equal(compiler.compilerPath, cachedPath); // Version should be parsed from the binary, not from config - assert.equal(compiler.version, "0.1.4"); - assert.equal(compiler.longVersion, "0.1.4+slang"); - }); - }); - - 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: "slang", - version: "0.8.34", - path: "/custom/path/to/slang", - }), - ]; - - // Should not throw β€” download is skipped for configs with path - await hooks.downloadCompilers!(context, configs, true); + assert.match(compiler.version, /^\d+\.\d+\.\d+/); + assert.equal(compiler.longVersion, `${compiler.version}+slang`); }); }); }); diff --git a/packages/hardhat-slang/test/mirror-availability.ts b/packages/hardhat-slang/test/mirror-availability.ts index 1e5fc1930bb..65d06214a72 100644 --- a/packages/hardhat-slang/test/mirror-availability.ts +++ b/packages/hardhat-slang/test/mirror-availability.ts @@ -1,3 +1,5 @@ +import type { SlangRelease } from "../src/internal/constants.js"; + import assert from "node:assert/strict"; import { describe, it } from "node:test"; @@ -7,38 +9,39 @@ import { } from "@nomicfoundation/hardhat-utils/request"; import { - SOLIDITY_TO_SOLX_VERSION_MAP, + SLANG_RELEASES, SLANG_RELEASES_BASE_URL, } from "../src/internal/constants.js"; // Mirrors the per-platform asset names built in src/internal/platform.ts. -function assetNames(version: string): string[] { +function assetNames(version: string, release: SlangRelease): string[] { + const suffix = release.assetOverride?.assetSuffix ?? `v${version}`; return [ - `solx-linux-amd64-gnu-v${version}`, - `solx-linux-arm64-gnu-v${version}`, - `solx-macosx-v${version}`, - `solx-windows-amd64-gnu-v${version}.exe`, + `solx-linux-amd64-gnu-${suffix}`, + `solx-linux-arm64-gnu-${suffix}`, + `solx-macosx-${suffix}`, + `solx-windows-amd64-gnu-${suffix}.exe`, ]; } describe( - "slang releases mirror availability", - // Opt-in: run by the path-filtered slang-mirror-availability job in ci.yml, + "slang releases availability", + // Opt-in: run by the path-filtered mirror-availability job in ci.yml, // not on every PR that rebuilds this package. { skip: process.env.HARDHAT_RUN_MIRROR_TESTS !== "true" }, () => { - for (const [solidityVersion, slangVersion] of Object.entries( - SOLIDITY_TO_SOLX_VERSION_MAP, - )) { - it(`serves every slang ${slangVersion} asset and checksum (mapped from Solidity ${solidityVersion})`, async () => { + for (const [slangVersion, release] of Object.entries(SLANG_RELEASES)) { + const baseUrl = release.assetOverride?.baseUrl ?? SLANG_RELEASES_BASE_URL; + + it(`serves every slang ${slangVersion} asset and checksum from ${baseUrl}`, async () => { const missing: string[] = []; - for (const asset of assetNames(slangVersion)) { + for (const asset of assetNames(slangVersion, release)) { // Sidecars too: the downloader refuses to use a binary it can't verify for (const file of [asset, `${asset}.sha256`]) { try { const response = await getRequest( - `${SLANG_RELEASES_BASE_URL}/${file}`, - // 1-byte range: don't pull the ~60 MB binaries. + `${baseUrl}/${file}`, + // 1-byte range: don't pull the ~100 MB binaries. { extraHeaders: { Range: "bytes=0-0" } }, // isTestDispatcher drops keep-alive to 10ms; these dispatchers // are never closed and would otherwise hang the suite. @@ -60,7 +63,7 @@ describe( assert.deepEqual( missing, [], - `the mirror does not serve: ${missing.join(", ")} β€” the version map must not point at a slang release before solx-releases-mirror serves its assets`, + `not served: ${missing.join(", ")} β€” the release table must not point at a slang release before its assets are published`, ); }); } diff --git a/packages/hardhat-slang/test/platform.ts b/packages/hardhat-slang/test/platform.ts index f6d24b115fa..6d77a69eedf 100644 --- a/packages/hardhat-slang/test/platform.ts +++ b/packages/hardhat-slang/test/platform.ts @@ -1,11 +1,25 @@ +import type { SlangRelease } from "../src/internal/constants.js"; + import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { SLANG_RELEASES } from "../src/internal/constants.js"; import { getSlangAssetName, getSlangBinaryBaseName, } from "../src/internal/platform.js"; +const PINNED_RELEASE = SLANG_RELEASES["0.1.0-pre.2026-10-01"]; + +// A release served by the mirror under the standard `v${version}` naming. +const MIRROR_RELEASE: SlangRelease = { + minSolidity: "0.8.0", + maxSolidity: "0.8.36", + extraArgs: [], +}; + +const EXE = process.platform === "win32" ? ".exe" : ""; + describe("hardhat-slang platform detection", () => { it("returns a valid base name for the current platform", () => { const baseName = getSlangBinaryBaseName(); @@ -33,15 +47,17 @@ describe("hardhat-slang platform detection", () => { } }); - it("asset name includes version suffix", () => { - const assetName = getSlangAssetName("0.1.4"); - assert.ok( - assetName.includes("-v0.1.4"), - `asset name should include version suffix: ${assetName}`, + it("names a mirror-served release's asset with the version suffix", () => { + assert.equal( + getSlangAssetName("0.2.0", MIRROR_RELEASE), + `${getSlangBinaryBaseName()}-v0.2.0${EXE}`, ); - assert.ok( - assetName.startsWith("solx-"), - `asset name should start with 'solx-': ${assetName}`, + }); + + it("names the pinned prerelease's asset with its override suffix instead of the version", () => { + assert.equal( + getSlangAssetName("0.1.0-pre.2026-10-01", PINNED_RELEASE), + `${getSlangBinaryBaseName()}-slang-2026-10-01${EXE}`, ); }); }); From 5a6baa3b9d160d9be0189540ed0f9828f489c49f Mon Sep 17 00:00:00 2001 From: Marian Fechete Date: Thu, 1 Oct 2026 18:06:22 +0200 Subject: [PATCH 4/9] Drive the slang binary with the release row's args and the job's Solidity version --- .../src/internal/hook-handlers/solidity.ts | 32 +++- .../src/internal/slang-compiler.ts | 36 +++- .../test/hook-handlers/solidity.ts | 61 ++++++- packages/hardhat-slang/test/slang-compiler.ts | 156 ++++++++++++++---- 4 files changed, 244 insertions(+), 41 deletions(-) diff --git a/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts b/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts index 0834eaf8496..cedc9d3bf7a 100644 --- a/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts +++ b/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts @@ -62,6 +62,28 @@ function getPinnedRelease(config: HardhatConfig): { return { version, release }; } +/** + * Returns the release row whose CLI arguments drive a custom-path binary: the + * pinned release when there is one, otherwise the newest release the plugin + * knows, since a custom binary is usually a build ahead of any release. + */ +function getReleaseForCustomPath(config: HardhatConfig): SlangRelease { + const pinned = + config.slang.version !== undefined + ? SLANG_RELEASES[config.slang.version] + : undefined; + if (pinned !== undefined) { + return pinned; + } + + const newest = Object.values(SLANG_RELEASES).at(-1); + assertHardhatInvariant( + newest !== undefined, + "The slang release table must not be empty", + ); + return newest; +} + export default async (): Promise> => ({ downloadCompilers: async (context, compilerConfigs, quiet) => { // Every type: "slang" entry without a custom path shares the single @@ -124,7 +146,10 @@ export default async (): Promise> => ({ `Creating SlangCompiler with custom path for Solidity ${compilerConfig.version} (slang ${customSlangVersion}) at ${compilerConfig.path}`, ); - return new SlangCompiler(customSlangVersion, compilerConfig.path); + return new SlangCompiler(customSlangVersion, compilerConfig.path, { + release: getReleaseForCustomPath(context.config), + targetSolidityVersion: compilerConfig.version, + }); } const { version: slangVersion, release } = getPinnedRelease(context.config); @@ -140,6 +165,9 @@ export default async (): Promise> => ({ `Creating SlangCompiler for Solidity ${compilerConfig.version} (slang ${slangVersion}) at ${binaryPath}`, ); - return new SlangCompiler(slangVersion, binaryPath); + return new SlangCompiler(slangVersion, binaryPath, { + release, + targetSolidityVersion: compilerConfig.version, + }); }, }); diff --git a/packages/hardhat-slang/src/internal/slang-compiler.ts b/packages/hardhat-slang/src/internal/slang-compiler.ts index d6415b08ca7..66aaa9c8504 100644 --- a/packages/hardhat-slang/src/internal/slang-compiler.ts +++ b/packages/hardhat-slang/src/internal/slang-compiler.ts @@ -1,3 +1,4 @@ +import type { SlangRelease } from "./constants.js"; import type { Compiler, CompilerInput, @@ -13,29 +14,56 @@ export const SLANG_DEBUG_INFO_SELECTORS: readonly string[] = [ "evm.deployedBytecode.debugInfo", ] as const; +export interface SlangCompilerOptions { + /** The release table row of the slang binary being driven. */ + release: SlangRelease; + /** + * The Solidity version Hardhat selected for the compilation job. slang + * compiles a range of Solidity versions, so the job's version is passed to + * the binary through the release's `targetVersionFlag`, when it has one. + */ + targetSolidityVersion: string; + /** Intended for tests. */ + spawnCompile?: typeof defaultSpawnCompile; +} + export class SlangCompiler implements Compiler { public readonly version: string; public readonly longVersion: string; public readonly compilerPath: string; public readonly isSolcJs: boolean = false; + /** The CLI arguments passed to the binary on every compile. */ + public readonly args: readonly string[]; + readonly #spawnCompile: typeof defaultSpawnCompile; constructor( slangVersion: string, compilerPath: string, - spawnCompile: typeof defaultSpawnCompile = defaultSpawnCompile, + options: SlangCompilerOptions, ) { + const { + release, + targetSolidityVersion, + spawnCompile = defaultSpawnCompile, + } = options; + this.version = slangVersion; this.longVersion = `${slangVersion}+slang`; this.compilerPath = compilerPath; + this.args = [ + "--standard-json", + ...release.extraArgs, + ...(release.targetVersionFlag !== undefined + ? [release.targetVersionFlag, targetSolidityVersion] + : []), + ]; this.#spawnCompile = spawnCompile; } public async compile(input: CompilerInput): Promise { - const args = ["--standard-json", "--no-import-callback"]; - - return await this.#spawnCompile(this.compilerPath, args, input); + return await this.#spawnCompile(this.compilerPath, [...this.args], input); } } diff --git a/packages/hardhat-slang/test/hook-handlers/solidity.ts b/packages/hardhat-slang/test/hook-handlers/solidity.ts index 370b09bd0b7..cef3500b672 100644 --- a/packages/hardhat-slang/test/hook-handlers/solidity.ts +++ b/packages/hardhat-slang/test/hook-handlers/solidity.ts @@ -3,7 +3,7 @@ import type { SolidityCompilerConfig } from "hardhat/types/config"; import type { CompilerInput, CompilerOutput } from "hardhat/types/solidity"; import assert from "node:assert/strict"; -import { writeFile } from "node:fs/promises"; +import { chmod, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; @@ -24,6 +24,7 @@ import { getSlangBinaryPath } from "../../src/internal/downloader.js"; import solidityHookHandlers, { parseSlangVersion, } from "../../src/internal/hook-handlers/solidity.js"; +import { SlangCompiler } from "../../src/internal/slang-compiler.js"; const PINNED_VERSION = "0.1.0-pre.2026-10-01"; const PINNED_RELEASE = SLANG_RELEASES[PINNED_VERSION]; @@ -287,6 +288,23 @@ describe("hardhat-slang solidity hook handler", () => { assert.equal(compiler.isSolcJs, false); }); + it("drives the binary with the pinned release's args", async () => { + const hooks = await solidityHookHandlers(); + await cacheFakePinnedBinary(); + + const compiler = await hooks.getCompiler!( + createContext(), + createSolidityCompilerConfig({ type: "slang", version: "0.8.20" }), + createGetCompilerMockNext().next, + ); + + assert.ok(compiler instanceof SlangCompiler, "expected a SlangCompiler"); + assert.deepEqual(compiler.args, [ + "--standard-json", + ...PINNED_RELEASE.extraArgs, + ]); + }); + it("throws an invariant error when the pinned binary isn't cached", async () => { const hooks = await solidityHookHandlers(); @@ -343,6 +361,47 @@ describe("hardhat-slang solidity hook handler", () => { ); }); + it( + "reads the version from a custom-path binary and drives it with the newest release's args when nothing is pinned", + { skip: process.platform === "win32" }, + async () => { + const hooks = await solidityHookHandlers(); + + // A stand-in binary that only knows how to answer --version. + const fakeBinaryPath = path.join(tmpDir, "fake-slang"); + await writeFile( + fakeBinaryPath, + '#!/bin/sh\necho "solx v0.0.1-fake, LLVM-based Solidity compiler for the EVM, Front end: Slang"\n', + ); + await chmod(fakeBinaryPath, 0o755); + + const mockNext = createGetCompilerMockNext(); + const compiler = await hooks.getCompiler!( + createContext(null), + createSolidityCompilerConfig({ + type: "slang", + version: "0.8.20", + path: fakeBinaryPath, + }), + mockNext.next, + ); + + assert.ok(!mockNext.wasCalled(), "next should NOT have been called"); + assert.ok( + compiler instanceof SlangCompiler, + "expected a SlangCompiler", + ); + assert.equal(compiler.compilerPath, fakeBinaryPath); + assert.equal(compiler.version, "0.0.1-fake"); + assert.equal(compiler.longVersion, "0.0.1-fake+slang"); + const newest = Object.values(SLANG_RELEASES).at(-1)!; + assert.deepEqual(compiler.args, [ + "--standard-json", + ...newest.extraArgs, + ]); + }, + ); + it("returns SlangCompiler with version from binary when path is provided", async () => { // Use the real prerelease binary if it is in the global cache, skip otherwise resetMockCacheDir(); diff --git a/packages/hardhat-slang/test/slang-compiler.ts b/packages/hardhat-slang/test/slang-compiler.ts index dd5b2f445f1..38d4cce7daa 100644 --- a/packages/hardhat-slang/test/slang-compiler.ts +++ b/packages/hardhat-slang/test/slang-compiler.ts @@ -1,14 +1,28 @@ +import type { SlangRelease } from "../src/internal/constants.js"; import type { CompilerInput, CompilerOutput } from "hardhat/types/solidity"; import assert from "node:assert/strict"; import { beforeEach, describe, it } from "node:test"; +import { SLANG_RELEASES } from "../src/internal/constants.js"; import { SLANG_DEBUG_INFO_SELECTORS, SlangCompiler, addSlangDebugInfoSelectors, } from "../src/internal/slang-compiler.js"; +const PINNED_VERSION = "0.1.0-pre.2026-10-01"; +const PINNED_RELEASE = SLANG_RELEASES[PINNED_VERSION]; + +// What a real release row is expected to look like once the compiler accepts +// the target Solidity version: no import-callback flag, a version flag. +const RELEASE_WITH_VERSION_FLAG: SlangRelease = { + minSolidity: "0.8.0", + maxSolidity: "0.8.36", + extraArgs: [], + targetVersionFlag: "--solidity-version", +}; + // Track calls to the fake spawnCompile let spawnCompileCalls: Array<{ command: string; @@ -26,57 +40,131 @@ async function fakeSpawnCompile( return fakeOutput; } +const sampleInput: CompilerInput = { + language: "Solidity", + sources: { "A.sol": { content: "pragma solidity ^0.8.0;" } }, + settings: { optimizer: { enabled: true }, outputSelection: {} }, +}; + describe("SlangCompiler", () => { beforeEach(() => { spawnCompileCalls = []; }); it("implements the Compiler interface", async () => { - const compiler = new SlangCompiler("0.1.4", "/path/to/slang"); + const compiler = new SlangCompiler(PINNED_VERSION, "/path/to/slang", { + release: PINNED_RELEASE, + targetSolidityVersion: "0.8.34", + }); - assert.equal(compiler.version, "0.1.4"); - assert.equal(compiler.longVersion, "0.1.4+slang"); + assert.equal(compiler.version, PINNED_VERSION); + assert.equal(compiler.longVersion, `${PINNED_VERSION}+slang`); assert.equal(compiler.compilerPath, "/path/to/slang"); assert.equal(compiler.isSolcJs, false); }); - it("forwards binary path, args, and the input unchanged to spawnCompile", async () => { - const compiler = new SlangCompiler( - "0.1.4", - "/path/to/slang", - fakeSpawnCompile, - ); - const input: CompilerInput = { - language: "Solidity", - sources: { "A.sol": { content: "pragma solidity ^0.8.0;" } }, - settings: { optimizer: { enabled: true }, outputSelection: {} }, - }; + it("forwards binary path, the release's extra args, and the input unchanged to spawnCompile", async () => { + const compiler = new SlangCompiler(PINNED_VERSION, "/path/to/slang", { + release: PINNED_RELEASE, + targetSolidityVersion: "0.8.34", + spawnCompile: fakeSpawnCompile, + }); - await compiler.compile(input); + await compiler.compile(sampleInput); assert.equal(spawnCompileCalls.length, 1); const call = spawnCompileCalls[0]; assert.equal(call.command, "/path/to/slang"); + // The prerelease row keeps --no-import-callback and has no version flag, so + // the prerelease binary is never given an argument it rejects. assert.deepEqual(call.args, ["--standard-json", "--no-import-callback"]); - // compile() transforms nothing β€” slang's defaults (optimizer mode, viaIR, - // debugInfo) are applied at config resolution, so the input reaches slang - // exactly as the build system constructed it. - assert.equal(call.input, input); + // compile() transforms nothing β€” the plugin's defaults (optimizer mode, + // debugInfo) are applied at config resolution, so the input reaches the + // compiler exactly as the build system constructed it. + assert.equal(call.input, sampleInput); }); - it("returns the output from spawnCompile", async () => { - const compiler = new SlangCompiler( - "0.1.4", - "/path/to/slang", - fakeSpawnCompile, + it("passes only --standard-json when the release has no extra args or version flag", async () => { + const compiler = new SlangCompiler("0.2.0", "/path/to/slang", { + release: { minSolidity: "0.8.0", maxSolidity: "0.8.36", extraArgs: [] }, + targetSolidityVersion: "0.8.34", + spawnCompile: fakeSpawnCompile, + }); + + await compiler.compile(sampleInput); + + assert.deepEqual(spawnCompileCalls[0].args, ["--standard-json"]); + }); + + it("appends the release's version flag and the target Solidity version", async () => { + const compiler = new SlangCompiler("0.2.0", "/path/to/slang", { + release: RELEASE_WITH_VERSION_FLAG, + targetSolidityVersion: "0.8.20", + spawnCompile: fakeSpawnCompile, + }); + + await compiler.compile(sampleInput); + + assert.deepEqual(spawnCompileCalls[0].args, [ + "--standard-json", + "--solidity-version", + "0.8.20", + ]); + }); + + it("puts the release's extra args before the version flag", async () => { + const compiler = new SlangCompiler("0.2.0", "/path/to/slang", { + release: { + ...RELEASE_WITH_VERSION_FLAG, + extraArgs: ["--no-import-callback"], + }, + targetSolidityVersion: "0.8.20", + spawnCompile: fakeSpawnCompile, + }); + + await compiler.compile(sampleInput); + + assert.deepEqual(spawnCompileCalls[0].args, [ + "--standard-json", + "--no-import-callback", + "--solidity-version", + "0.8.20", + ]); + }); + + it("gives two Solidity versions the same binary and long version, differing only in the target", async () => { + const forNewest = new SlangCompiler("0.2.0", "/path/to/slang", { + release: RELEASE_WITH_VERSION_FLAG, + targetSolidityVersion: "0.8.34", + spawnCompile: fakeSpawnCompile, + }); + const forOlder = new SlangCompiler("0.2.0", "/path/to/slang", { + release: RELEASE_WITH_VERSION_FLAG, + targetSolidityVersion: "0.8.20", + spawnCompile: fakeSpawnCompile, + }); + + // Core requires longVersion to be deterministic per compiler version. + assert.equal(forNewest.compilerPath, forOlder.compilerPath); + assert.equal(forNewest.longVersion, forOlder.longVersion); + + await forNewest.compile(sampleInput); + await forOlder.compile(sampleInput); + + assert.deepEqual( + spawnCompileCalls.map((c) => c.args.at(-1)), + ["0.8.34", "0.8.20"], ); - const input: CompilerInput = { - language: "Solidity", - sources: { "A.sol": { content: "pragma solidity ^0.8.0;" } }, - settings: { optimizer: {}, outputSelection: {} }, - }; + }); + + it("returns the output from spawnCompile", async () => { + const compiler = new SlangCompiler(PINNED_VERSION, "/path/to/slang", { + release: PINNED_RELEASE, + targetSolidityVersion: "0.8.34", + spawnCompile: fakeSpawnCompile, + }); - const result = await compiler.compile(input); + const result = await compiler.compile(sampleInput); assert.equal(result, fakeOutput); }); }); @@ -109,10 +197,10 @@ describe("addSlangDebugInfoSelectors", () => { }); it("does not mutate the input object", async () => { - const input = { "*": { "*": ["abi"] } }; - const before = JSON.stringify(input); - await addSlangDebugInfoSelectors(input); - assert.equal(JSON.stringify(input), before); + const input2 = { "*": { "*": ["abi"] } }; + const before = JSON.stringify(input2); + await addSlangDebugInfoSelectors(input2); + assert.equal(JSON.stringify(input2), before); }); it("accepts undefined input (sets up the wildcard slot)", async () => { From 4b63ff5188b26aba6fcb86258608b12e90b29362 Mon Sep 17 00:00:00 2001 From: Marian Fechete Date: Thu, 1 Oct 2026 18:06:22 +0200 Subject: [PATCH 5/9] Document the hardhat-slang plugin and gate the debug-info test --- packages/hardhat-slang/README.md | 119 ++++++++++-------- .../hardhat-slang/test/output-augmentation.ts | 8 +- 2 files changed, 75 insertions(+), 52 deletions(-) diff --git a/packages/hardhat-slang/README.md b/packages/hardhat-slang/README.md index f1bd7bc1acc..3985e4d5267 100644 --- a/packages/hardhat-slang/README.md +++ b/packages/hardhat-slang/README.md @@ -1,50 +1,49 @@ -# Hardhat Slang Solx plugin +# Hardhat Slang plugin -This plugin enables the [slang-solx](https://github.com/NomicFoundation/solx) Solidity compiler in Hardhat. `slang-solx` is a Solidity compiler based on solc and LLVM. It compiles faster, produces better optimized bytecode, and gets rid of β€œstack too deep” errors. +This plugin enables the new `slang` Solidity compiler in Hardhat. -The `slang-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 `slang-solx` in other build profiles, see configuration flags below. - -The compiler was originally developed at Matter Labs under the name `solx`. It moved to the Nomic Foundation in 2025, where it was renamed `slang-solx`. Its repository and release assets still use the `solx` name. - -> πŸ’‘ **Tip:** Read the [Compiling with slang-solx](https://hardhat.org/docs/cookbook/compiling-with-slang-solx) guide for more details. +The `slang` compiler is experimental and is not ready for production use-cases. Use it for test builds and test execution locally, and keep using `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 `slang` in other build profiles, see the configuration flags below. ## Installation ```bash -npm install --save-dev @nomicfoundation/hardhat-slang-solx +npm install --save-dev @nomicfoundation/hardhat-slang ``` -Then add the plugin to your `hardhat.config.ts` and create a `slang-solx` build profile. You must use the build profiles config format, which requires both a `default` and a `slang-solx` profile: +Then add the plugin to your `hardhat.config.ts`, pin a `slang` release, and create a `slang` build profile. You must use the build profiles config format, which requires both a `default` and a `slang` profile: ```typescript import { defineConfig } from "hardhat/config"; -import hardhatSlangSolx from "@nomicfoundation/hardhat-slang-solx"; +import hardhatSlang from "@nomicfoundation/hardhat-slang"; export default defineConfig({ - plugins: [hardhatSlangSolx], + plugins: [hardhatSlang], solidity: { profiles: { default: { - version: "0.8.29", + version: "0.8.34", }, - "slang-solx": { - type: "slang-solx", + slang: { + type: "slang", version: "0.8.34", }, }, }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, }); ``` -The `default` profile uses solc as usual. The `slang-solx` profile uses the slang-solx compiler, identified by `type: "slang-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 the hardhat-slang-solx plugin. See more details below for the currently supported Solidity versions and EVM versions. +The `default` profile uses solc as usual. The `slang` profile uses the slang compiler, identified by `type: "slang"`. The `version` of a `type: "slang"` entry is still the Solidity version, exactly as with solc: Hardhat uses it to resolve pragmas and group files into compilation jobs. Which `slang` binary compiles them is decided separately, by `slang.version`. ## Usage -Run tests or compile using the slang-solx build profile: +Run tests or compile using the slang build profile: ```bash -hardhat test --build-profile slang-solx -hardhat build --build-profile slang-solx +hardhat test --build-profile slang +hardhat build --build-profile slang ``` The default profile continues to use solc as usual: @@ -55,57 +54,81 @@ hardhat build # uses solc (default profile) ## Configuration +### Pinning a slang release + +Unlike solc, one `slang` binary compiles a range of Solidity versions (`0.8.x`). The plugin therefore downloads a single release, the one pinned in `slang.version`, and uses it for every `type: "slang"` compiler entry. Each entry's Solidity `version` must fall inside the range supported by the pinned release (see [Supported Solidity versions](#supported-solidity-versions)); anything outside is a validation error. + +`slang.version` is required whenever a `type: "slang"` entry needs a download. Entries with a custom `path` (see below) don't. + ### Multi-version example -You can configure the `slang-solx` profile with multiple compilers. Compilers without `type: "slang-solx"` will use solc: +You can configure the `slang` profile with multiple compilers. Compilers without `type: "slang"` will use solc: ```typescript export default defineConfig({ - plugins: [hardhatSlangSolx], + plugins: [hardhatSlang], solidity: { profiles: { default: { compilers: [{ version: "0.8.34" }, { version: "0.8.20" }], }, - "slang-solx": { + slang: { compilers: [ - { type: "slang-solx", version: "0.8.34" }, - { version: "0.8.20" }, // uses solc, slang-solx doesn't support this version + { type: "slang", version: "0.8.34" }, // compiled by the pinned slang release + { type: "slang", version: "0.8.20" }, // same binary, different Solidity version + { version: "0.7.6" }, // uses solc; outside the slang range ], }, }, }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, }); ``` ### Options -- `dangerouslyAllowSlangSolxInProduction` (`boolean`, default: `false`), allows compiler type `"slang-solx"` in build profiles other than `slang-solx`. By default, using `type: "slang-solx"` in any other profile (e.g. `default`, `production`) will produce a validation error. +- `version` (`string`), the slang release to download and compile with. See [Supported Solidity versions](#supported-solidity-versions) for the known releases. +- `dangerouslyAllowSlangInProduction` (`boolean`, default: `false`), allows compiler type `"slang"` in build profiles other than `slang`. By default, using `type: "slang"` in any other profile (e.g. `default`, `production`) will produce a validation error. ```typescript export default defineConfig({ - plugins: [hardhatSlangSolx], + plugins: [hardhatSlang], solidity: { profiles: { default: { - type: "slang-solx", // allowed only because of the option below + type: "slang", // not recommended, allowed only because of the option below version: "0.8.34", }, - "slang-solx": { - type: "slang-solx", + slang: { + type: "slang", version: "0.8.34", }, }, }, - "slang-solx": { - dangerouslyAllowSlangSolxInProduction: true, + slang: { + version: "0.1.0-pre.2026-10-01", + dangerouslyAllowSlangInProduction: true, }, }); ``` +### Using a custom binary + +Point a compiler entry at a locally built binary with `path`. The entry skips the download and the Solidity range check, and the plugin reads the compiler version from the binary's `--version` output: + +```typescript +slang: { + type: "slang", + version: "0.8.34", + path: "/path/to/slang", +}, +``` + ### Optimization level -slang-solx optimizes via LLVM; set the level per profile with `settings.optimizer.mode`: +slang optimizes via LLVM; set the level per profile with `settings.optimizer.mode`: | Mode | What it does | | ----- | ----------------------------------------------------- | @@ -115,46 +138,40 @@ slang-solx optimizes via LLVM; set the level per profile with `settings.optimize | `"s"` | Smaller bytecode. | | `"z"` | Smallest bytecode. | -slang-solx has two independent optimizer knobs: - -- `optimizer.mode` (above) is the LLVM backend level. There is **no "off"** β€” the minimum is `"1"`, so LLVM always optimizes. -- `optimizer.enabled` is the embedded solc front end's own optimizer, `false` by default. It only affects the legacy pipeline, where it optimizes the EVM assembly slang-solx translates. Under `viaIR: true` it changes nothing but the metadata hash: slang-solx bypasses the Yul optimizer entirely, so the IR handed to LLVM is always unoptimized. +There is **no "off"**: the minimum is `"1"`, so LLVM always optimizes. The solc-specific knobs `optimizer.enabled` and `viaIR` have no effect on the slang pipeline, which has neither the solc optimizer nor a Yul step; the plugin passes them through untouched. For example, optimizing for size instead of performance: ```typescript import { defineConfig } from "hardhat/config"; -import hardhatSlangSolx from "@nomicfoundation/hardhat-slang-solx"; +import hardhatSlang from "@nomicfoundation/hardhat-slang"; export default defineConfig({ - plugins: [hardhatSlangSolx], + plugins: [hardhatSlang], solidity: { profiles: { default: { version: "0.8.34" }, - "slang-solx": { - type: "slang-solx", + slang: { + type: "slang", version: "0.8.34", settings: { optimizer: { mode: "z" } }, // optimize for size }, }, }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, }); ``` -Or, on the legacy pipeline, run the front end's assembly optimizer before LLVM `-O3` (both knobs on) β€” just the `slang-solx` profile: - -```typescript -"slang-solx": { - type: "slang-solx", - version: "0.8.34", - settings: { optimizer: { enabled: true, mode: "3" } }, -}, -``` - ### Supported Solidity versions -slang-solx maps each Solidity version to a specific slang-solx binary version internally. Currently supported: `0.8.34` (solx 0.1.8). Earlier slang-solx releases did not emit the DWARF debug info that EDR relies on for Solidity stack traces, so they are not supported by this plugin. +Each slang release supports a range of Solidity versions: + +| slang release | Solidity versions | Notes | +| --- | --- | --- | +| `0.1.0-pre.2026-10-01` | `0.8.0` to `0.8.37` | Internal prerelease (solx tag `b74af542`, 2026-10-01) downloaded from GitHub. | ### EVM version support -slang-solx supports EVM versions `cancun`, `prague`, and `osaka`. Using an older EVM target (e.g., `paris`, `shanghai`) with compiler type `"slang-solx"` will result in a validation error. +slang supports EVM versions `cancun`, `prague`, and `osaka`. Using an older EVM target (e.g., `paris`, `shanghai`) with compiler type `"slang"` will result in a validation error. diff --git a/packages/hardhat-slang/test/output-augmentation.ts b/packages/hardhat-slang/test/output-augmentation.ts index c80b27c78ce..c0c11123d0b 100644 --- a/packages/hardhat-slang/test/output-augmentation.ts +++ b/packages/hardhat-slang/test/output-augmentation.ts @@ -22,7 +22,13 @@ const ELF_MAGIC_HEX = "7f454c46"; describe( "hardhat-slang output augmentation", - { skip: process.env.HARDHAT_DISABLE_SLOW_TESTS === "true" }, + { + // The prerelease binary doesn't emit debugInfo yet. Opt in once the pinned + // release does, so this turns on without a code change. + skip: + process.env.HARDHAT_DISABLE_SLOW_TESTS === "true" || + process.env.HARDHAT_SLANG_EXPECT_DEBUG_INFO !== "true", + }, () => { useFixtureProject("with-debug-info"); From 127a1b3456e7ef68de6e140eb82e416247c3e3ce Mon Sep 17 00:00:00 2001 From: John Kane Date: Tue, 6 Oct 2026 14:15:21 +0000 Subject: [PATCH 6/9] chore: remove hardhat-slang from publish flow We don't mark the package as private (this is to allow local testing via verdaccio). However, we do exclude it from release in the publish scripts. The release builds its publish list from a pnpm publish dry run, so excluding the package there keeps it out of every later step. The local command for regenerating that list gets the same exclusion. This crude filter will be reverted, likely when we merge `hardhat-slang` into core as a built-in plugin. --- .changeset/config.json | 1 + .github/workflows/release.yml | 3 ++- scripts/github-release/file-helpers.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 4e460112274..d8a8974eaae 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -12,6 +12,7 @@ "ignore": [ "@nomicfoundation/config", "@nomicfoundation/example-project", + "@nomicfoundation/hardhat-slang", "@nomicfoundation/template-package", "template-*" ], diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d36dad17e2d..7ff7c07f0ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,8 +169,9 @@ jobs: - name: Build All Packages run: pnpm run --recursive --no-bail --filter './packages/**' --if-present build + # hardhat-slang is excluded, once part of core this filter can be dropped - name: Publish All Packages (dry-run) and save list of packages that need publishing - run: pnpm publish --filter "./packages/**" -r --no-git-checks --access public --dry-run --report-summary + run: pnpm publish --filter "./packages/**" --filter "!@nomicfoundation/hardhat-slang" -r --no-git-checks --access public --dry-run --report-summary - name: Store the package list to be published uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/scripts/github-release/file-helpers.ts b/scripts/github-release/file-helpers.ts index 5d1c9495b9d..112b22f2c12 100644 --- a/scripts/github-release/file-helpers.ts +++ b/scripts/github-release/file-helpers.ts @@ -18,7 +18,7 @@ export async function readPublishSummary(): Promise<{ console.error( "Could not read pnpm-publish-summary.json.\n\n" + "It should be generated in the CI, to generate it locally run:\n" + - ' pnpm publish --filter "./packages/**" -r --no-git-checks --access public --report-summary --dry-run', + ' pnpm publish --filter "./packages/**" --filter "!@nomicfoundation/hardhat-slang" -r --no-git-checks --access public --report-summary --dry-run', ); process.exit(1); From 2809b737532d8b69b052cf958f66a624c4d9d0eb Mon Sep 17 00:00:00 2001 From: John Kane Date: Tue, 6 Oct 2026 14:48:20 +0000 Subject: [PATCH 7/9] chore: align hardhat-slang prettier with main and regenerate the lockfile main moved prettier to 3.9.9, so merging it left the lockfile pointing hardhat-slang at a 3.9.8 entry that no longer exists. The lockfile is regenerated from main's, which also drops @typescript-eslint 8.70.0 entries nothing references. --- packages/hardhat-slang/package.json | 2 +- pnpm-lock.yaml | 93 +---------------------------- 2 files changed, 3 insertions(+), 92 deletions(-) diff --git a/packages/hardhat-slang/package.json b/packages/hardhat-slang/package.json index 652bf7e8213..cf3c0877389 100644 --- a/packages/hardhat-slang/package.json +++ b/packages/hardhat-slang/package.json @@ -52,7 +52,7 @@ "c8": "^12.0.0", "eslint": "9.39.5", "hardhat": "workspace:^3.15.0", - "prettier": "3.9.8", + "prettier": "3.9.9", "rimraf": "^6.1.3", "tsx": "^4.23.1", "typescript": "~6.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8544928d01..11028cef62c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1178,8 +1178,8 @@ importers: specifier: workspace:^3.15.0 version: link:../hardhat prettier: - specifier: 3.9.8 - version: 3.9.8 + specifier: 3.9.9 + version: 3.9.9 rimraf: specifier: ^6.1.3 version: 6.1.3 @@ -3491,13 +3491,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.70.0': - resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.70.1': resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3505,32 +3498,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.70.0': - resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.70.1': resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.70.0': - resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.70.1': resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.70.0': - resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.70.1': resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3544,20 +3521,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.70.0': - resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.70.1': resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.70.0': - resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.70.1': resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3571,10 +3538,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.70.0': - resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.70.1': resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -8973,18 +8936,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.70.0 - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.5(supports-color@8.1.1) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.70.1 @@ -8997,15 +8948,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.0(supports-color@8.1.1)(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) - '@typescript-eslint/types': 8.70.0 - debug: 4.4.3(supports-color@8.1.1) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.70.1(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.3) @@ -9015,20 +8957,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.70.0': - dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 - '@typescript-eslint/scope-manager@8.70.1': dependencies: '@typescript-eslint/types': 8.70.1 '@typescript-eslint/visitor-keys': 8.70.1 - '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -9045,25 +8978,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.70.0': {} - '@typescript-eslint/types@8.70.1': {} - '@typescript-eslint/typescript-estree@8.70.0(supports-color@8.1.1)(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.70.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 - debug: 4.4.3(supports-color@8.1.1) - minimatch: 10.2.6 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.70.1(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.70.1(supports-color@8.1.1)(typescript@6.0.3) @@ -9090,11 +9006,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.70.0': - dependencies: - '@typescript-eslint/types': 8.70.0 - eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.70.1': dependencies: '@typescript-eslint/types': 8.70.1 From f9aed882e9bb2811d37d66356d0005e9063ab209 Mon Sep 17 00:00:00 2001 From: Marian Fechete Date: Wed, 14 Oct 2026 20:48:23 +0200 Subject: [PATCH 8/9] chore: regenerate the lockfile after merging main --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56770ef53e..a9937cca87b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1167,7 +1167,7 @@ importers: version: link:../hardhat-test-utils '@types/node': specifier: ^22.0.0 - version: 22.20.4 + version: 22.20.5 c8: specifier: ^12.0.0 version: 12.0.0 From af1b1fb118e1fbec7caf585b388aa9f703fd11ec Mon Sep 17 00:00:00 2001 From: Marian Fechete Date: Wed, 14 Oct 2026 22:05:07 +0200 Subject: [PATCH 9/9] chore: exclude the unpublished hardhat-slang from the merge-group release check --- scripts/lib/packages.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/lib/packages.ts b/scripts/lib/packages.ts index bb3655750b1..e11128078dc 100644 --- a/scripts/lib/packages.ts +++ b/scripts/lib/packages.ts @@ -13,6 +13,9 @@ export async function readAllReleasablePackages() { "example-project", "template-package", "hardhat-test-utils", + // hardhat-slang is not published yet, see the publish filter in + // release.yml; drop this once it is part of the release flow. + "hardhat-slang", ].includes(file), );