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/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index c1e53655115..f945f39e637 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 = { @@ -3528,4 +3541,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..3985e4d5267 --- /dev/null +++ b/packages/hardhat-slang/README.md @@ -0,0 +1,177 @@ +# Hardhat Slang plugin + +This plugin enables the new `slang` Solidity compiler in Hardhat. + +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 +``` + +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 hardhatSlang from "@nomicfoundation/hardhat-slang"; + +export default defineConfig({ + plugins: [hardhatSlang], + solidity: { + profiles: { + default: { + version: "0.8.34", + }, + 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` 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 build profile: + +```bash +hardhat test --build-profile slang +hardhat build --build-profile slang +``` + +The default profile continues to use solc as usual: + +```bash +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` profile with multiple compilers. Compilers without `type: "slang"` will use solc: + +```typescript +export default defineConfig({ + plugins: [hardhatSlang], + solidity: { + profiles: { + default: { + compilers: [{ version: "0.8.34" }, { version: "0.8.20" }], + }, + slang: { + compilers: [ + { 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 + +- `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: [hardhatSlang], + solidity: { + profiles: { + default: { + type: "slang", // not recommended, allowed only because of the option below + version: "0.8.34", + }, + slang: { + type: "slang", + version: "0.8.34", + }, + }, + }, + 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 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. | + +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 hardhatSlang from "@nomicfoundation/hardhat-slang"; + +export default defineConfig({ + plugins: [hardhatSlang], + solidity: { + profiles: { + default: { version: "0.8.34" }, + slang: { + type: "slang", + version: "0.8.34", + settings: { optimizer: { mode: "z" } }, // optimize for size + }, + }, + }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, +}); +``` + +### Supported Solidity versions + +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 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/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..cf3c0877389 --- /dev/null +++ b/packages/hardhat-slang/package.json @@ -0,0 +1,69 @@ +{ + "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-slang-solx": "workspace:^", + "@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.9", + "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..53c896779f9 --- /dev/null +++ b/packages/hardhat-slang/src/internal/constants.ts @@ -0,0 +1,81 @@ +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"; + +/** + * 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/downloader.ts b/packages/hardhat-slang/src/internal/downloader.ts new file mode 100644 index 00000000000..4d57de28d99 --- /dev/null +++ b/packages/hardhat-slang/src/internal/downloader.ts @@ -0,0 +1,288 @@ +import type { SlangRelease } from "./constants.js"; +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 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, release); + 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 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.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, release); + + // 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, 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( + 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..3e520320549 --- /dev/null +++ b/packages/hardhat-slang/src/internal/hook-handlers/config.ts @@ -0,0 +1,456 @@ +import type { SlangRelease } from "../constants.js"; +import type { + ConfigurationVariableResolver, + HardhatConfig, + HardhatUserConfig, + SlangConfig, + SlangUserConfig, +} from "hardhat/types/config"; +import type { + ConfigHooks, + HardhatConfigValidationError, + 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, + validateUserConfigZodType, +} from "@nomicfoundation/hardhat-zod-utils"; +import { z } from "zod"; + +import { + DEFAULT_SLANG_OPTIMIZER_MODE, + SLANG_COMPILER_TYPE, + SLANG_RELEASES, + 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 KNOWN_SLANG_VERSIONS = Object.keys(SLANG_RELEASES); + +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(); + +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 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, + 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. + 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?: SlangUserConfig): SlangConfig { + return { + version: userConfig?.version, + 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..cedc9d3bf7a --- /dev/null +++ b/packages/hardhat-slang/src/internal/hook-handlers/solidity.ts @@ -0,0 +1,173 @@ +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"; +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 { SLANG_COMPILER_TYPE, SLANG_RELEASES } 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); +} + +/** + * 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`, + ); + + const release = SLANG_RELEASES[version]; + assertHardhatInvariant( + release !== undefined, + `Unknown slang version ${version} — this should have been caught by config validation`, + ); + + 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 + // 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 ${c.version}: custom path provided`, + ); + return false; + } + return true; + }); + + if (!needsDownload) { + return; + } + + 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) => { + 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, { + release: getReleaseForCustomPath(context.config), + targetSolidityVersion: compilerConfig.version, + }); + } + + const { version: slangVersion, release } = getPinnedRelease(context.config); + + const binaryPath = await getSlangBinaryPath(slangVersion, release); + + 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, { + release, + targetSolidityVersion: compilerConfig.version, + }); + }, +}); diff --git a/packages/hardhat-slang/src/internal/platform.ts b/packages/hardhat-slang/src/internal/platform.ts new file mode 100644 index 00000000000..fdc4e36502a --- /dev/null +++ b/packages/hardhat-slang/src/internal/platform.ts @@ -0,0 +1,53 @@ +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), + * unless the release overrides the suffix. + * + * 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) + * 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, + }, + ); +} + +/** + * 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}-${suffix}.exe`; + } + return `${baseName}-${suffix}`; +} 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..66aaa9c8504 --- /dev/null +++ b/packages/hardhat-slang/src/internal/slang-compiler.ts @@ -0,0 +1,97 @@ +import type { SlangRelease } from "./constants.js"; +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 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, + 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 { + return await this.#spawnCompile(this.compilerPath, [...this.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..9041f0dc16f --- /dev/null +++ b/packages/hardhat-slang/src/type-extensions.ts @@ -0,0 +1,78 @@ +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 { + /** + * 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. + */ + dangerouslyAllowSlangInProduction?: boolean; + } + + 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; + } + + 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..45b1064a5f6 --- /dev/null +++ b/packages/hardhat-slang/test/config.ts @@ -0,0 +1,1024 @@ +/* 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, + validateUserConfig, +} 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({ + 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"); + }); + + 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", () => { + 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); + assert.equal(resolvedConfig.slang.version, undefined); + }); + + 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("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( + {}, + 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("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( + {}, + 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("does not inject viaIR, and passes a user-set viaIR through untouched", 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.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); + 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. + 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( + 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")), + `Expected EVM version error, got: ${errors.map((e) => e.message).join(", ")}`, + ); + }); + + it("rejects type: 'slang' with shanghai evmVersion", async () => { + const errors = await validateUserConfig( + slangProfileConfig([ + { + version: "0.8.34", + type: "slang", + settings: { evmVersion: "shanghai" }, + }, + ]), + ); + assert.ok(errors.length > 0, "Should have validation errors"); + }); + + 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' without evmVersion", async () => { + 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( + 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( + 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"), + `Error path should include 'overrides', got: ${JSON.stringify(errors[0].path)}`, + ); + }); +}); + +describe("hardhat-slang Solidity version validation", () => { + 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[0].message.includes(PINNED_VERSION), + `the error should list the known releases, got: ${errors[0].message}`, + ); + }); + + 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 paths = rangeErrors(errors).map((e) => e.path); + assert.deepEqual(paths, [ + [ + "solidity", + "profiles", + "slang", + "overrides", + "contracts/Old.sol", + "version", + ], + ]); + }); + + it("checks the range of a single-version build profile", async () => { + const errors = await validateUserConfig({ + solidity: { + profiles: { + slang: { type: "slang", version: "0.7.6" }, + }, + }, + slang: { version: PINNED_VERSION }, + }); + const paths = rangeErrors(errors).map((e) => e.path); + assert.deepEqual(paths, [["solidity", "profiles", "slang", "version"]]); + }); + + it("checks the range of top-level compilers outside build profiles", async () => { + const errors = await validateUserConfig({ + solidity: { + compilers: [{ type: "slang", version: "0.7.6" }], + }, + slang: { version: PINNED_VERSION }, + }); + 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(errors, []); + }); + + 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", + }, + ]), + ); + 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); + }); +}); + +describe("hardhat-slang resolved config validation", () => { + function makeResolvedConfig( + profiles: Record, + opts?: { dangerouslyAllowSlangInProduction?: boolean }, + ): any { + return { + solidity: { + profiles, + npmFilesToBuild: [], + registeredCompilerTypes: ["solc", "slang"], + }, + slang: { + version: PINNED_VERSION, + 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( + slangProfileConfig([ + { + 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( + slangProfileConfig([ + { + 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..ed260c4a71d --- /dev/null +++ b/packages/hardhat-slang/test/downloader.ts @@ -0,0 +1,376 @@ +import type { SlangRelease } from "../src/internal/constants.js"; +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, + 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"; + +// 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; + +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, + asset = assetName, + ): void { + interceptable + .intercept({ path: `/${asset}.sha256`, method: "GET" }) + .reply(200, body) + .times(times); + } + + function interceptBinary( + interceptable: Interceptable, + body: string, + times = 1, + asset = assetName, + ): void { + interceptable + .intercept({ path: `/${asset}`, method: "GET" }) + .reply(200, body) + .times(times); + } + + beforeEach(async () => { + tmpDir = await makeWorkspaceTmpDir("slang-downloader"); + setMockCacheDir(tmpDir); + + assetName = getSlangAssetName(TEST_SLANG_VERSION, TEST_RELEASE); + 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("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, + TEST_RELEASE, + noop, + { dispatcher: mockAgent }, + ); + + 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") { + return; + } + + const { mode } = await stat(binaryPath); + assert.equal( + mode.toString(8).slice(-3), + "755", + "the binary should be executable", + ); + }); + + 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, + TEST_RELEASE, + 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, + TEST_RELEASE, + 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, TEST_RELEASE, 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, TEST_RELEASE, 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, TEST_RELEASE)), + 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, TEST_RELEASE, 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, TEST_RELEASE)), + 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, TEST_RELEASE, 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, TEST_RELEASE)), + 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, + TEST_RELEASE, + 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, + TEST_RELEASE, + 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, + TEST_RELEASE, + ); + + await ensureDir(path.dirname(binaryPath)); + await writeFile(binaryPath, BINARY_CONTENTS); + + assert.equal( + await downloadSlang(TEST_SLANG_VERSION, TEST_RELEASE, 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..03592ca55a0 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/simple/hardhat.config.ts @@ -0,0 +1,23 @@ +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", + }, + }, + }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, + 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..03592ca55a0 --- /dev/null +++ b/packages/hardhat-slang/test/fixture-projects/with-debug-info/hardhat.config.ts @@ -0,0 +1,23 @@ +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", + }, + }, + }, + slang: { + version: "0.1.0-pre.2026-10-01", + }, + 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..cef3500b672 --- /dev/null +++ b/packages/hardhat-slang/test/hook-handlers/solidity.ts @@ -0,0 +1,441 @@ +/* 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 { chmod, 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"; +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]; + +// Helper to create a compiler config +function createSolidityCompilerConfig( + overrides: Partial = {}, +): SolidityCompilerConfig { + return { + version: "0.8.34", + settings: { + optimizer: {}, + outputSelection: {}, + }, + ...overrides, + }; +} + +// 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; + 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( + "solx, LLVM-based Solidity compiler for the EVM v0.1.3, LLVM revision: v1.0.2, LLVM build: a33d492", + ), + "0.1.3", + ); + }); + + it("parses version from nightly build output", () => { + assert.equal( + parseSlangVersion( + "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("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 hooks = await solidityHookHandlers(); + + assert.ok( + hooks.downloadCompilers !== undefined, + "downloadCompilers hook should be defined", + ); + }); + + it("does nothing when no slang-typed compilers present", async () => { + const hooks = await solidityHookHandlers(); + + // 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" }), + ]; + + await hooks.downloadCompilers!(createContext(null), configs, true); + + assert.equal( + await exists(await getSlangBinaryPath(PINNED_VERSION, PINNED_RELEASE)), + false, + "nothing should have been downloaded", + ); + }); + + it("skips download when every slang config has a custom path", async () => { + const hooks = await solidityHookHandlers(); + + 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" }), + ]; + + // 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); + + assert.equal(configs[0].path, undefined, "paths must not be mutated"); + assert.equal(configs[1].path, undefined, "paths must 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 hooks = await solidityHookHandlers(); + + assert.ok( + hooks.getCompiler !== undefined, + "getCompiler hook should be defined", + ); + }); + + it("passes through to next for non-slang compiler configs", async () => { + const hooks = await solidityHookHandlers(); + + const compilerConfig = createSolidityCompilerConfig({ type: "solc" }); + const mockNext = createGetCompilerMockNext(); + + const result = await hooks.getCompiler!( + createContext(null), + 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 hooks = await solidityHookHandlers(); + + const compilerConfig = createSolidityCompilerConfig({ type: undefined }); + const mockNext = createGetCompilerMockNext(); + + const result = await hooks.getCompiler!( + createContext(null), + compilerConfig, + mockNext.next, + ); + + assert.ok(mockNext.wasCalled(), "next should have been called"); + assert.equal(result, mockNext.compiler); + }); + + it("returns a SlangCompiler for the pinned release when its binary is cached", async () => { + const hooks = await solidityHookHandlers(); + const binaryPath = await cacheFakePinnedBinary(); + + const compilerConfig = createSolidityCompilerConfig({ + type: "slang", + 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("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(); + + 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!(createContext(null), compilerConfig, mockNext.next), + HardhatError.ERRORS.CORE.INTERNAL.ASSERTION_ERROR, + { + message: + "slang.version is not set — this should have been caught by config validation", + }, + ); + }); + + it("throws HardhatError when path does not exist", async () => { + const hooks = await solidityHookHandlers(); + + const compilerConfig = createSolidityCompilerConfig({ + type: "slang", + version: "0.8.34", + path: "/nonexistent/path/to/slang", + }); + const mockNext = createGetCompilerMockNext(); + + await assertRejectsWithHardhatError( + hooks.getCompiler!(createContext(null), compilerConfig, mockNext.next), + HardhatError.ERRORS.HARDHAT_SLANG.GENERAL.BINARY_NOT_FOUND, + { + path: "/nonexistent/path/to/slang", + }, + ); + }); + + 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(); + const cachedPath = await getSlangBinaryPath( + PINNED_VERSION, + PINNED_RELEASE, + ); + if (!(await exists(cachedPath))) { + return; + } + + const hooks = await solidityHookHandlers(); + + const compilerConfig = createSolidityCompilerConfig({ + type: "slang", + version: "0.8.34", + path: cachedPath, + }); + const mockNext = createGetCompilerMockNext(); + + const compiler = await hooks.getCompiler!( + createContext(null), + 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.match(compiler.version, /^\d+\.\d+\.\d+/); + assert.equal(compiler.longVersion, `${compiler.version}+slang`); + }); + }); +}); diff --git a/packages/hardhat-slang/test/integration.ts b/packages/hardhat-slang/test/integration.ts new file mode 100644 index 00000000000..8e41ca94a47 --- /dev/null +++ b/packages/hardhat-slang/test/integration.ts @@ -0,0 +1,129 @@ +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); + assert.equal(hre.config.slang.version, "0.1.0-pre.2026-10-01"); + }); + + 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", + }, + }, + }, + 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(); + + 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..65d06214a72 --- /dev/null +++ b/packages/hardhat-slang/test/mirror-availability.ts @@ -0,0 +1,71 @@ +import type { SlangRelease } from "../src/internal/constants.js"; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + getRequest, + ResponseStatusCodeError, +} from "@nomicfoundation/hardhat-utils/request"; + +import { + 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, release: SlangRelease): string[] { + const suffix = release.assetOverride?.assetSuffix ?? `v${version}`; + return [ + `solx-linux-amd64-gnu-${suffix}`, + `solx-linux-arm64-gnu-${suffix}`, + `solx-macosx-${suffix}`, + `solx-windows-amd64-gnu-${suffix}.exe`, + ]; +} + +describe( + "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 [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, 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( + `${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. + { 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, + [], + `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/optimizer-mode.ts b/packages/hardhat-slang/test/optimizer-mode.ts new file mode 100644 index 00000000000..1701834929f --- /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, + undefined, + "the slang pipeline has no Yul step, so the plugin must not inject viaIR", + ); + }); +}); diff --git a/packages/hardhat-slang/test/output-augmentation.ts b/packages/hardhat-slang/test/output-augmentation.ts new file mode 100644 index 00000000000..c0c11123d0b --- /dev/null +++ b/packages/hardhat-slang/test/output-augmentation.ts @@ -0,0 +1,152 @@ +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", + { + // 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"); + + 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..6d77a69eedf --- /dev/null +++ b/packages/hardhat-slang/test/platform.ts @@ -0,0 +1,63 @@ +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(); + 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("names a mirror-served release's asset with the version suffix", () => { + assert.equal( + getSlangAssetName("0.2.0", MIRROR_RELEASE), + `${getSlangBinaryBaseName()}-v0.2.0${EXE}`, + ); + }); + + 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}`, + ); + }); +}); diff --git a/packages/hardhat-slang/test/slang-compiler.ts b/packages/hardhat-slang/test/slang-compiler.ts new file mode 100644 index 00000000000..38d4cce7daa --- /dev/null +++ b/packages/hardhat-slang/test/slang-compiler.ts @@ -0,0 +1,212 @@ +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; + 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; +} + +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(PINNED_VERSION, "/path/to/slang", { + release: PINNED_RELEASE, + targetSolidityVersion: "0.8.34", + }); + + 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, 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(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 — 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("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"], + ); + }); + + 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(sampleInput); + 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 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 () => { + 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..dcc48c9cb68 --- /dev/null +++ b/packages/hardhat-slang/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../config/tsconfig.base.json", + "references": [ + { + "path": "../hardhat" + }, + { + "path": "../hardhat-errors" + }, + { + "path": "../hardhat-node-test-reporter" + }, + { + "path": "../hardhat-slang-solx" + }, + { + "path": "../hardhat-test-utils" + }, + { + "path": "../hardhat-utils" + }, + { + "path": "../hardhat-zod-utils" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05197d5707c..a9937cca87b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1141,6 +1141,55 @@ 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-slang-solx': + specifier: workspace:^ + version: link:../hardhat-slang-solx + '@nomicfoundation/hardhat-test-utils': + specifier: workspace:^ + version: link:../hardhat-test-utils + '@types/node': + specifier: ^22.0.0 + version: 22.20.5 + 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.9 + version: 3.9.9 + 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': 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); 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), );