UNPKG

hardhat

Version:

Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.

976 lines (818 loc) 27.4 kB
import debug from "debug"; import fsExtra from "fs-extra"; import * as os from "os"; import * as path from "path"; import fsPromises from "fs/promises"; import { Artifact, Artifacts as IArtifacts, BuildInfo, CompilerInput, CompilerOutput, DebugFile, } from "../types"; import { getFullyQualifiedName, isFullyQualifiedName, parseFullyQualifiedName, findDistance, } from "../utils/contract-names"; import { replaceBackslashes } from "../utils/source-names"; import { ARTIFACT_FORMAT_VERSION, BUILD_INFO_DIR_NAME, BUILD_INFO_FORMAT_VERSION, DEBUG_FILE_FORMAT_VERSION, EDIT_DISTANCE_THRESHOLD, } from "./constants"; import { HardhatError } from "./core/errors"; import { ERRORS } from "./core/errors-list"; import { createNonCryptographicHashBasedIdentifier } from "./util/hash"; import { FileNotFoundError, getAllFilesMatching, getAllFilesMatchingSync, getFileTrueCase, getFileTrueCaseSync, } from "./util/fs-utils"; const log = debug("hardhat:core:artifacts"); interface Cache { artifactPaths?: string[]; debugFilePaths?: string[]; buildInfoPaths?: string[]; artifactNameToArtifactPathCache: Map<string, string>; artifactFQNToBuildInfoPathCache: Map<string, string>; } export class Artifacts implements IArtifacts { private _validArtifacts: Array<{ sourceName: string; artifacts: string[] }>; // Undefined means that the cache is disabled. private _cache?: Cache = { artifactNameToArtifactPathCache: new Map(), artifactFQNToBuildInfoPathCache: new Map(), }; constructor(private _artifactsPath: string) { this._validArtifacts = []; } public addValidArtifacts( validArtifacts: Array<{ sourceName: string; artifacts: string[] }> ) { this._validArtifacts.push(...validArtifacts); } public async readArtifact(name: string): Promise<Artifact> { const artifactPath = await this._getArtifactPath(name); return fsExtra.readJson(artifactPath); } public readArtifactSync(name: string): Artifact { const artifactPath = this._getArtifactPathSync(name); return fsExtra.readJsonSync(artifactPath); } public async artifactExists(name: string): Promise<boolean> { let artifactPath; try { artifactPath = await this._getArtifactPath(name); } catch (e) { if (HardhatError.isHardhatError(e)) { return false; } // eslint-disable-next-line @nomicfoundation/hardhat-internal-rules/only-hardhat-error throw e; } return fsExtra.pathExists(artifactPath); } public async getAllFullyQualifiedNames(): Promise<string[]> { const paths = await this.getArtifactPaths(); return paths.map((p) => this._getFullyQualifiedNameFromPath(p)).sort(); } public async getBuildInfo( fullyQualifiedName: string ): Promise<BuildInfo | undefined> { let buildInfoPath = this._cache?.artifactFQNToBuildInfoPathCache.get(fullyQualifiedName); if (buildInfoPath === undefined) { const artifactPath = this.formArtifactPathFromFullyQualifiedName(fullyQualifiedName); const debugFilePath = this._getDebugFilePath(artifactPath); buildInfoPath = await this._getBuildInfoFromDebugFile(debugFilePath); if (buildInfoPath === undefined) { return undefined; } this._cache?.artifactFQNToBuildInfoPathCache.set( fullyQualifiedName, buildInfoPath ); } return fsExtra.readJSON(buildInfoPath); } public getBuildInfoSync(fullyQualifiedName: string): BuildInfo | undefined { let buildInfoPath = this._cache?.artifactFQNToBuildInfoPathCache.get(fullyQualifiedName); if (buildInfoPath === undefined) { const artifactPath = this.formArtifactPathFromFullyQualifiedName(fullyQualifiedName); const debugFilePath = this._getDebugFilePath(artifactPath); buildInfoPath = this._getBuildInfoFromDebugFileSync(debugFilePath); if (buildInfoPath === undefined) { return undefined; } this._cache?.artifactFQNToBuildInfoPathCache.set( fullyQualifiedName, buildInfoPath ); } return fsExtra.readJSONSync(buildInfoPath); } public async getArtifactPaths(): Promise<string[]> { const cached = this._cache?.artifactPaths; if (cached !== undefined) { return cached; } const paths = await getAllFilesMatching(this._artifactsPath, (f) => this._isArtifactPath(f) ); const result = paths.sort(); if (this._cache !== undefined) { this._cache.artifactPaths = result; } return result; } public async getBuildInfoPaths(): Promise<string[]> { const cached = this._cache?.buildInfoPaths; if (cached !== undefined) { return cached; } const paths = await getAllFilesMatching( path.join(this._artifactsPath, BUILD_INFO_DIR_NAME), (f) => f.endsWith(".json") ); const result = paths.sort(); if (this._cache !== undefined) { this._cache.buildInfoPaths = result; } return result; } public async getDebugFilePaths(): Promise<string[]> { const cached = this._cache?.debugFilePaths; if (cached !== undefined) { return cached; } const paths = await getAllFilesMatching( path.join(this._artifactsPath), (f) => f.endsWith(".dbg.json") ); const result = paths.sort(); if (this._cache !== undefined) { this._cache.debugFilePaths = result; } return result; } public async saveArtifactAndDebugFile( artifact: Artifact, pathToBuildInfo?: string ) { try { // artifact const fullyQualifiedName = getFullyQualifiedName( artifact.sourceName, artifact.contractName ); const artifactPath = this.formArtifactPathFromFullyQualifiedName(fullyQualifiedName); await fsExtra.ensureDir(path.dirname(artifactPath)); await Promise.all([ fsExtra.writeJSON(artifactPath, artifact, { spaces: 2, }), (async () => { if (pathToBuildInfo === undefined) { return; } // save debug file const debugFilePath = this._getDebugFilePath(artifactPath); const debugFile = this._createDebugFile( artifactPath, pathToBuildInfo ); await fsExtra.writeJSON(debugFilePath, debugFile, { spaces: 2, }); })(), ]); } finally { this.clearCache(); } } public async saveBuildInfo( solcVersion: string, solcLongVersion: string, input: CompilerInput, output: CompilerOutput ): Promise<string> { try { const buildInfoDir = path.join(this._artifactsPath, BUILD_INFO_DIR_NAME); await fsExtra.ensureDir(buildInfoDir); const buildInfoName = this._getBuildInfoName( solcVersion, solcLongVersion, input ); const buildInfo = this._createBuildInfo( buildInfoName, solcVersion, solcLongVersion, input, output ); const buildInfoPath = path.join(buildInfoDir, `${buildInfoName}.json`); // JSON.stringify of the entire build info can be really slow // in larger projects, so we stringify per part and incrementally create // the JSON in the file. // // We split this code into different curly-brace-enclosed scopes so that // partial JSON strings get out of scope sooner and hence can be reclaimed // by the GC if needed. const file = await fsPromises.open(buildInfoPath, "w"); try { { const withoutOutput = JSON.stringify({ ...buildInfo, output: undefined, }); // We write the JSON (without output) except the last } await file.write(withoutOutput.slice(0, -1)); } { const outputWithoutSourcesAndContracts = JSON.stringify({ ...buildInfo.output, sources: undefined, contracts: undefined, }); // We start writing the output await file.write(',"output":'); // Write the output object except for the last } await file.write(outputWithoutSourcesAndContracts.slice(0, -1)); // If there were other field apart from sources and contracts we need // a comma if (outputWithoutSourcesAndContracts.length > 2) { await file.write(","); } } // Writing the sources await file.write('"sources":{'); let isFirst = true; for (const [name, value] of Object.entries( buildInfo.output.sources ?? {} )) { if (isFirst) { isFirst = false; } else { await file.write(","); } await file.write(`${JSON.stringify(name)}:${JSON.stringify(value)}`); } // Close sources object await file.write("}"); // Writing the contracts await file.write(',"contracts":{'); isFirst = true; for (const [name, value] of Object.entries( buildInfo.output.contracts ?? {} )) { if (isFirst) { isFirst = false; } else { await file.write(","); } await file.write(`${JSON.stringify(name)}:${JSON.stringify(value)}`); } // close contracts object await file.write("}"); // close output object await file.write("}"); // close build info object await file.write("}"); } finally { await file.close(); } return buildInfoPath; } finally { this.clearCache(); } } /** * Remove all artifacts that don't correspond to the current solidity files */ public async removeObsoleteArtifacts() { // We clear the cache here, as we want to be sure this runs correctly this.clearCache(); try { const validArtifactPaths = await Promise.all( this._validArtifacts.flatMap(({ sourceName, artifacts }) => artifacts.map((artifactName) => this._getArtifactPath( getFullyQualifiedName(sourceName, artifactName) ) ) ) ); const validArtifactsPathsSet = new Set<string>(validArtifactPaths); for (const { sourceName, artifacts } of this._validArtifacts) { for (const artifactName of artifacts) { validArtifactsPathsSet.add( this.formArtifactPathFromFullyQualifiedName( getFullyQualifiedName(sourceName, artifactName) ) ); } } const existingArtifactsPaths = await this.getArtifactPaths(); await Promise.all( existingArtifactsPaths .filter((artifactPath) => !validArtifactsPathsSet.has(artifactPath)) .map((artifactPath) => this._removeArtifactFiles(artifactPath)) ); await this._removeObsoleteBuildInfos(); } finally { // We clear the cache here, as this may have non-existent paths now this.clearCache(); } } /** * Returns the absolute path to the given artifact * @throws {HardhatError} If the name is not fully qualified. */ public formArtifactPathFromFullyQualifiedName( fullyQualifiedName: string ): string { const { sourceName, contractName } = parseFullyQualifiedName(fullyQualifiedName); return path.join(this._artifactsPath, sourceName, `${contractName}.json`); } public clearCache() { // Avoid accidentally re-enabling the cache if (this._cache === undefined) { return; } this._cache = { artifactFQNToBuildInfoPathCache: new Map(), artifactNameToArtifactPathCache: new Map(), }; } public disableCache() { this._cache = undefined; } /** * Remove all build infos that aren't used by any debug file */ private async _removeObsoleteBuildInfos() { const debugFiles = await this.getDebugFilePaths(); const buildInfos = await Promise.all( debugFiles.map(async (debugFile) => { const buildInfoFile = await this._getBuildInfoFromDebugFile(debugFile); if (buildInfoFile !== undefined) { return path.resolve(path.dirname(debugFile), buildInfoFile); } }) ); const filteredBuildInfos: string[] = buildInfos.filter( (bf): bf is string => typeof bf === "string" ); const validBuildInfos = new Set<string>(filteredBuildInfos); const buildInfoFiles = await this.getBuildInfoPaths(); await Promise.all( buildInfoFiles .filter((buildInfoFile) => !validBuildInfos.has(buildInfoFile)) .map(async (buildInfoFile) => { log(`Removing buildInfo '${buildInfoFile}'`); await fsExtra.unlink(buildInfoFile); }) ); } private _getBuildInfoName( solcVersion: string, solcLongVersion: string, input: CompilerInput ): string { const json = JSON.stringify({ _format: BUILD_INFO_FORMAT_VERSION, solcVersion, solcLongVersion, input, }); return createNonCryptographicHashBasedIdentifier( Buffer.from(json) ).toString("hex"); } /** * Returns the absolute path to the artifact that corresponds to the given * name. * * If the name is fully qualified, the path is computed from it. If not, an * artifact that matches the given name is searched in the existing artifacts. * If there is an ambiguity, an error is thrown. * * @throws {HardhatError} with descriptor: * - {@link ERRORS.ARTIFACTS.WRONG_CASING} if the path case doesn't match the one in the filesystem. * - {@link ERRORS.ARTIFACTS.MULTIPLE_FOUND} if there are multiple artifacts matching the given contract name. * - {@link ERRORS.ARTIFACTS.NOT_FOUND} if the artifact is not found. */ private async _getArtifactPath(name: string): Promise<string> { const cached = this._cache?.artifactNameToArtifactPathCache.get(name); if (cached !== undefined) { return cached; } let result: string; if (isFullyQualifiedName(name)) { result = await this._getValidArtifactPathFromFullyQualifiedName(name); } else { const files = await this.getArtifactPaths(); result = this._getArtifactPathFromFiles(name, files); } this._cache?.artifactNameToArtifactPathCache.set(name, result); return result; } private _createBuildInfo( id: string, solcVersion: string, solcLongVersion: string, input: CompilerInput, output: CompilerOutput ): BuildInfo { return { id, _format: BUILD_INFO_FORMAT_VERSION, solcVersion, solcLongVersion, input, output, }; } private _createDebugFile(artifactPath: string, pathToBuildInfo: string) { const relativePathToBuildInfo = path.relative( path.dirname(artifactPath), pathToBuildInfo ); const debugFile: DebugFile = { _format: DEBUG_FILE_FORMAT_VERSION, buildInfo: relativePathToBuildInfo, }; return debugFile; } private _getArtifactPathsSync(): string[] { const cached = this._cache?.artifactPaths; if (cached !== undefined) { return cached; } const paths = getAllFilesMatchingSync(this._artifactsPath, (f) => this._isArtifactPath(f) ); const result = paths.sort(); if (this._cache !== undefined) { this._cache.artifactPaths = result; } return result; } /** * Sync version of _getArtifactPath */ private _getArtifactPathSync(name: string): string { const cached = this._cache?.artifactNameToArtifactPathCache.get(name); if (cached !== undefined) { return cached; } let result: string; if (isFullyQualifiedName(name)) { result = this._getValidArtifactPathFromFullyQualifiedNameSync(name); } else { const files = this._getArtifactPathsSync(); result = this._getArtifactPathFromFiles(name, files); } this._cache?.artifactNameToArtifactPathCache.set(name, result); return result; } /** * DO NOT DELETE OR CHANGE * * use this.formArtifactPathFromFullyQualifiedName instead * @deprecated until typechain migrates to public version * @see https://github.com/dethcrypto/TypeChain/issues/544 */ private _getArtifactPathFromFullyQualifiedName( fullyQualifiedName: string ): string { const { sourceName, contractName } = parseFullyQualifiedName(fullyQualifiedName); return path.join(this._artifactsPath, sourceName, `${contractName}.json`); } /** * Returns the absolute path to the artifact that corresponds to the given * fully qualified name. * @param fullyQualifiedName The fully qualified name of the contract. * @returns The absolute path to the artifact. * @throws {HardhatError} with descriptor: * - {@link ERRORS.CONTRACT_NAMES.INVALID_FULLY_QUALIFIED_NAME} If the name is not fully qualified. * - {@link ERRORS.ARTIFACTS.WRONG_CASING} If the path case doesn't match the one in the filesystem. * - {@link ERRORS.ARTIFACTS.NOT_FOUND} If the artifact is not found. */ private async _getValidArtifactPathFromFullyQualifiedName( fullyQualifiedName: string ): Promise<string> { const artifactPath = this.formArtifactPathFromFullyQualifiedName(fullyQualifiedName); try { const trueCasePath = path.join( this._artifactsPath, await getFileTrueCase( this._artifactsPath, path.relative(this._artifactsPath, artifactPath) ) ); if (artifactPath !== trueCasePath) { throw new HardhatError(ERRORS.ARTIFACTS.WRONG_CASING, { correct: this._getFullyQualifiedNameFromPath(trueCasePath), incorrect: fullyQualifiedName, }); } return trueCasePath; } catch (e) { if (e instanceof FileNotFoundError) { return this._handleWrongArtifactForFullyQualifiedName( fullyQualifiedName ); } // eslint-disable-next-line @nomicfoundation/hardhat-internal-rules/only-hardhat-error throw e; } } private _getAllContractNamesFromFiles(files: string[]): string[] { return files.map((file) => { const fqn = this._getFullyQualifiedNameFromPath(file); return parseFullyQualifiedName(fqn).contractName; }); } private _getAllFullyQualifiedNamesSync(): string[] { const paths = this._getArtifactPathsSync(); return paths.map((p) => this._getFullyQualifiedNameFromPath(p)).sort(); } private _formatSuggestions(names: string[], contractName: string): string { switch (names.length) { case 0: return ""; case 1: return `Did you mean "${names[0]}"?`; default: return `We found some that were similar: ${names.map((n) => ` * ${n}`).join(os.EOL)} Please replace "${contractName}" for the correct contract name wherever you are trying to read its artifact. `; } } /** * @throws {HardhatError} with a list of similar contract names. */ private _handleWrongArtifactForFullyQualifiedName( fullyQualifiedName: string ): never { const names = this._getAllFullyQualifiedNamesSync(); const similarNames = this._getSimilarContractNames( fullyQualifiedName, names ); throw new HardhatError(ERRORS.ARTIFACTS.NOT_FOUND, { contractName: fullyQualifiedName, suggestion: this._formatSuggestions(similarNames, fullyQualifiedName), }); } /** * @throws {HardhatError} with a list of similar contract names. */ private _handleWrongArtifactForContractName( contractName: string, files: string[] ): never { const names = this._getAllContractNamesFromFiles(files); let similarNames = this._getSimilarContractNames(contractName, names); if (similarNames.length > 1) { similarNames = this._filterDuplicatesAsFullyQualifiedNames( files, similarNames ); } throw new HardhatError(ERRORS.ARTIFACTS.NOT_FOUND, { contractName, suggestion: this._formatSuggestions(similarNames, contractName), }); } /** * If the project has these contracts: * - 'contracts/Greeter.sol:Greeter' * - 'contracts/Meeter.sol:Greeter' * - 'contracts/Greater.sol:Greater' * And the user tries to get an artifact with the name 'Greter', then * the suggestions will be 'Greeter', 'Greeter', and 'Greater'. * * We don't want to show duplicates here, so we use FQNs for those. The * suggestions will then be: * - 'contracts/Greeter.sol:Greeter' * - 'contracts/Meeter.sol:Greeter' * - 'Greater' */ private _filterDuplicatesAsFullyQualifiedNames( files: string[], similarNames: string[] ): string[] { const outputNames = []; const groups = similarNames.reduce((obj, cur) => { // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions obj[cur] = obj[cur] ? obj[cur] + 1 : 1; return obj; }, {} as { [k: string]: number }); for (const [name, occurrences] of Object.entries(groups)) { if (occurrences > 1) { for (const file of files) { if (path.basename(file) === `${name}.json`) { outputNames.push(this._getFullyQualifiedNameFromPath(file)); } } continue; } outputNames.push(name); } return outputNames; } /** * * @param givenName can be FQN or contract name * @param names MUST match type of givenName (i.e. array of FQN's if givenName is FQN) * @returns */ private _getSimilarContractNames( givenName: string, names: string[] ): string[] { let shortestDistance = EDIT_DISTANCE_THRESHOLD; let mostSimilarNames: string[] = []; for (const name of names) { const distance = findDistance(givenName, name); if (distance < shortestDistance) { shortestDistance = distance; mostSimilarNames = [name]; continue; } if (distance === shortestDistance) { mostSimilarNames.push(name); continue; } } return mostSimilarNames; } private _getValidArtifactPathFromFullyQualifiedNameSync( fullyQualifiedName: string ): string { const artifactPath = this.formArtifactPathFromFullyQualifiedName(fullyQualifiedName); try { const trueCasePath = path.join( this._artifactsPath, getFileTrueCaseSync( this._artifactsPath, path.relative(this._artifactsPath, artifactPath) ) ); if (artifactPath !== trueCasePath) { throw new HardhatError(ERRORS.ARTIFACTS.WRONG_CASING, { correct: this._getFullyQualifiedNameFromPath(trueCasePath), incorrect: fullyQualifiedName, }); } return trueCasePath; } catch (e) { if (e instanceof FileNotFoundError) { return this._handleWrongArtifactForFullyQualifiedName( fullyQualifiedName ); } // eslint-disable-next-line @nomicfoundation/hardhat-internal-rules/only-hardhat-error throw e; } } private _getDebugFilePath(artifactPath: string): string { return artifactPath.replace(/\.json$/, ".dbg.json"); } /** * Gets the path to the artifact file for the given contract name. * @throws {HardhatError} with descriptor: * - {@link ERRORS.ARTIFACTS.NOT_FOUND} if there are no artifacts matching the given contract name. * - {@link ERRORS.ARTIFACTS.MULTIPLE_FOUND} if there are multiple artifacts matching the given contract name. */ private _getArtifactPathFromFiles( contractName: string, files: string[] ): string { const matchingFiles = files.filter((file) => { return path.basename(file) === `${contractName}.json`; }); if (matchingFiles.length === 0) { return this._handleWrongArtifactForContractName(contractName, files); } if (matchingFiles.length > 1) { const candidates = matchingFiles.map((file) => this._getFullyQualifiedNameFromPath(file) ); throw new HardhatError(ERRORS.ARTIFACTS.MULTIPLE_FOUND, { contractName, candidates: candidates.join(os.EOL), }); } return matchingFiles[0]; } /** * Returns the FQN of a contract giving the absolute path to its artifact. * * For example, given a path like * `/path/to/project/artifacts/contracts/Foo.sol/Bar.json`, it'll return the * FQN `contracts/Foo.sol:Bar` */ private _getFullyQualifiedNameFromPath(absolutePath: string): string { const sourceName = replaceBackslashes( path.relative(this._artifactsPath, path.dirname(absolutePath)) ); const contractName = path.basename(absolutePath).replace(".json", ""); return getFullyQualifiedName(sourceName, contractName); } /** * Remove the artifact file and its debug file. */ private async _removeArtifactFiles(artifactPath: string) { await fsExtra.remove(artifactPath); const debugFilePath = this._getDebugFilePath(artifactPath); await fsExtra.remove(debugFilePath); } /** * Given the path to a debug file, returns the absolute path to its * corresponding build info file if it exists, or undefined otherwise. */ private async _getBuildInfoFromDebugFile( debugFilePath: string ): Promise<string | undefined> { if (await fsExtra.pathExists(debugFilePath)) { const { buildInfo } = await fsExtra.readJson(debugFilePath); return path.resolve(path.dirname(debugFilePath), buildInfo); } return undefined; } /** * Sync version of _getBuildInfoFromDebugFile */ private _getBuildInfoFromDebugFileSync( debugFilePath: string ): string | undefined { if (fsExtra.pathExistsSync(debugFilePath)) { const { buildInfo } = fsExtra.readJsonSync(debugFilePath); return path.resolve(path.dirname(debugFilePath), buildInfo); } return undefined; } private _isArtifactPath(file: string) { return ( file.endsWith(".json") && file !== path.join(this._artifactsPath, "package.json") && !file.startsWith(path.join(this._artifactsPath, BUILD_INFO_DIR_NAME)) && !file.endsWith(".dbg.json") ); } } /** * Retrieves an artifact for the given `contractName` from the compilation output. * * @param sourceName The contract's source name. * @param contractName the contract's name. * @param contractOutput the contract's compilation output as emitted by `solc`. */ export function getArtifactFromContractOutput( sourceName: string, contractName: string, contractOutput: any ): Artifact { const evmBytecode = contractOutput.evm?.bytecode; let bytecode: string = evmBytecode?.object ?? ""; if (bytecode.slice(0, 2).toLowerCase() !== "0x") { bytecode = `0x${bytecode}`; } const evmDeployedBytecode = contractOutput.evm?.deployedBytecode; let deployedBytecode: string = evmDeployedBytecode?.object ?? ""; if (deployedBytecode.slice(0, 2).toLowerCase() !== "0x") { deployedBytecode = `0x${deployedBytecode}`; } const linkReferences = evmBytecode?.linkReferences ?? {}; const deployedLinkReferences = evmDeployedBytecode?.linkReferences ?? {}; return { _format: ARTIFACT_FORMAT_VERSION, contractName, sourceName, abi: contractOutput.abi, bytecode, deployedBytecode, linkReferences, deployedLinkReferences, }; }