diff options
Diffstat (limited to 'packages/sol-cov')
21 files changed, 930 insertions, 462 deletions
diff --git a/packages/sol-cov/CHANGELOG.json b/packages/sol-cov/CHANGELOG.json index 0d3303231..7e934ad6e 100644 --- a/packages/sol-cov/CHANGELOG.json +++ b/packages/sol-cov/CHANGELOG.json @@ -1,5 +1,58 @@ [ { + "version": "0.2.0", + "changes": [ + { + "note": "Fixed a bug causing RegExp to crash if contract code is longer that 32767 characters", + "pr": 675 + }, + { + "note": "Fixed a bug caused by Geth debug trace depth being 1-indexed", + "pr": 675 + }, + { + "note": "Fixed a bug when the tool crashed on empty traces", + "pr": 675 + }, + { + "note": "Use `BlockchainLifecycle` to support reverts on Geth", + "pr": 675 + }, + { + "note": "Add `ProfilerSubprovider` as a hacky way to profile code using coverage tools", + "pr": 675 + }, + { + "note": "Collect traces from `estimate_gas` calls", + "pr": 675 + }, + { + "note": "Fix a race condition caused by not awaiting the transaction before getting a trace", + "pr": 675 + }, + { + "note": "Add `start`/`stop` functionality to `CoverageSubprovider` and `ProfilerSubprovider`", + "pr": 675 + }, + { + "note": "Skip interface artifacts with a warning instead of failing", + "pr": 675 + }, + { + "note": "Fix solcVersion regex in parameter validation", + "pr": 690 + }, + { + "note": "Fix a bug when in TruffleArtifactsAdapter causing it to throw if compiler.json is not there", + "pr": 690 + }, + { + "note": "HUGE perf improvements", + "pr": 690 + } + ] + }, + { "timestamp": 1527009134, "version": "0.1.0", "changes": [ diff --git a/packages/sol-cov/package.json b/packages/sol-cov/package.json index 4d92a610b..1d9d7f94d 100644 --- a/packages/sol-cov/package.json +++ b/packages/sol-cov/package.json @@ -8,15 +8,18 @@ "main": "lib/src/index.js", "types": "lib/src/index.d.ts", "scripts": { - "watch": "tsc -w", + "watch_without_deps": "yarn pre_build && tsc -w", + "build": "yarn pre_build && tsc && copyfiles -u 3 './lib/src/monorepo_scripts/**/*' ./scripts", + "pre_build": "run-s copy_test_fixtures", "lint": "tslint --project .", - "test": "run-s clean build compile_test run_mocha", + "test": "run-s compile_test run_mocha", + "rebuild_and_test": "run-s clean build test", "test:coverage": "nyc npm run test --all && yarn coverage:report:lcov", "coverage:report:lcov": "nyc report --reporter=text-lcov > coverage/lcov.info", "test:circleci": "yarn test:coverage", - "run_mocha": "mocha lib/test/**/*_test.js --exit", + "run_mocha": "mocha --require source-map-support/register lib/test/**/*_test.js --exit", "clean": "shx rm -rf lib scripts test/fixtures/artifacts src/artifacts", - "build": "copyfiles 'test/fixtures/**/*' ./lib && tsc && copyfiles -u 3 './lib/src/monorepo_scripts/**/*' ./scripts", + "copy_test_fixtures": "copyfiles 'test/fixtures/**/*' ./lib", "compile_test": "sol-compiler compile", "manual:postpublish": "yarn build; node ./scripts/postpublish.js", "docs:stage": "node scripts/stage_docs.js", @@ -51,6 +54,8 @@ "@0xproject/subproviders": "^0.10.2", "@0xproject/typescript-typings": "^0.3.2", "@0xproject/utils": "^0.6.2", + "@0xproject/web3-wrapper": "^0.6.4", + "@0xproject/dev-utils": "^0.4.2", "ethereum-types": "^0.0.1", "ethereumjs-util": "^5.1.1", "glob": "^7.1.2", @@ -60,7 +65,7 @@ "mkdirp": "^0.5.1", "rimraf": "^2.6.2", "semaphore-async-await": "^1.5.1", - "solidity-parser-antlr": "^0.2.11" + "solidity-parser-antlr": "^0.2.12" }, "devDependencies": { "@0xproject/monorepo-scripts": "^0.1.20", diff --git a/packages/sol-cov/src/artifact_adapters/sol_compiler_artifact_adapter.ts b/packages/sol-cov/src/artifact_adapters/sol_compiler_artifact_adapter.ts index d08828bf6..220a9f98c 100644 --- a/packages/sol-cov/src/artifact_adapters/sol_compiler_artifact_adapter.ts +++ b/packages/sol-cov/src/artifact_adapters/sol_compiler_artifact_adapter.ts @@ -1,3 +1,5 @@ +import { CompilerOptions, ContractArtifact } from '@0xproject/sol-compiler'; +import { logUtils } from '@0xproject/utils'; import * as fs from 'fs'; import * as glob from 'glob'; import * as _ from 'lodash'; @@ -14,25 +16,30 @@ export class SolCompilerArtifactAdapter extends AbstractArtifactAdapter { private _sourcesPath: string; constructor(artifactsPath?: string, sourcesPath?: string) { super(); - const config = JSON.parse(fs.readFileSync(CONFIG_FILE).toString()); + const config: CompilerOptions = fs.existsSync(CONFIG_FILE) + ? JSON.parse(fs.readFileSync(CONFIG_FILE).toString()) + : {}; if (_.isUndefined(artifactsPath) && _.isUndefined(config.artifactsDir)) { throw new Error(`artifactsDir not found in ${CONFIG_FILE}`); } - this._artifactsPath = config.artifactsDir; + this._artifactsPath = (artifactsPath || config.artifactsDir) as string; if (_.isUndefined(sourcesPath) && _.isUndefined(config.contractsDir)) { throw new Error(`contractsDir not found in ${CONFIG_FILE}`); } - this._sourcesPath = config.contractsDir; + this._sourcesPath = (sourcesPath || config.contractsDir) as string; } public async collectContractsDataAsync(): Promise<ContractData[]> { const artifactsGlob = `${this._artifactsPath}/**/*.json`; const artifactFileNames = glob.sync(artifactsGlob, { absolute: true }); const contractsData: ContractData[] = []; for (const artifactFileName of artifactFileNames) { - const artifact = JSON.parse(fs.readFileSync(artifactFileName).toString()); + const artifact: ContractArtifact = JSON.parse(fs.readFileSync(artifactFileName).toString()); + if (_.isUndefined(artifact.compilerOutput.evm)) { + logUtils.warn(`${artifactFileName} doesn't contain bytecode. Skipping...`); + continue; + } let sources = _.keys(artifact.sources); sources = _.map(sources, relativeFilePath => path.resolve(this._sourcesPath, relativeFilePath)); - const contractName = artifact.contractName; const sourceCodes = _.map(sources, (source: string) => fs.readFileSync(source).toString()); const contractData = { sourceCodes, diff --git a/packages/sol-cov/src/artifact_adapters/truffle_artifact_adapter.ts b/packages/sol-cov/src/artifact_adapters/truffle_artifact_adapter.ts index c7f21b6eb..53449e5e1 100644 --- a/packages/sol-cov/src/artifact_adapters/truffle_artifact_adapter.ts +++ b/packages/sol-cov/src/artifact_adapters/truffle_artifact_adapter.ts @@ -1,8 +1,4 @@ import { Compiler, CompilerOptions } from '@0xproject/sol-compiler'; -import * as fs from 'fs'; -import * as glob from 'glob'; -import * as _ from 'lodash'; -import * as path from 'path'; import * as rimraf from 'rimraf'; import { ContractData } from '../types'; diff --git a/packages/sol-cov/src/ast_visitor.ts b/packages/sol-cov/src/ast_visitor.ts index 88309d3eb..16984b5ec 100644 --- a/packages/sol-cov/src/ast_visitor.ts +++ b/packages/sol-cov/src/ast_visitor.ts @@ -57,6 +57,9 @@ export class ASTVisitor { public ContinueStatement(ast: Parser.ContinueStatement): void { this._visitStatement(ast); } + public EmitStatement(ast: any /* TODO: Parser.EmitStatement */): void { + this._visitStatement(ast); + } public VariableDeclarationStatement(ast: Parser.VariableDeclarationStatement): void { this._visitStatement(ast); } diff --git a/packages/sol-cov/src/collect_coverage_entries.ts b/packages/sol-cov/src/collect_coverage_entries.ts index 97218616c..b145f044e 100644 --- a/packages/sol-cov/src/collect_coverage_entries.ts +++ b/packages/sol-cov/src/collect_coverage_entries.ts @@ -1,7 +1,5 @@ import * as ethUtil from 'ethereumjs-util'; -import * as fs from 'fs'; import * as _ from 'lodash'; -import * as path from 'path'; import * as parser from 'solidity-parser-antlr'; import { ASTVisitor, CoverageEntriesDescription } from './ast_visitor'; diff --git a/packages/sol-cov/src/coverage_manager.ts b/packages/sol-cov/src/coverage_manager.ts deleted file mode 100644 index 31b0e6fbc..000000000 --- a/packages/sol-cov/src/coverage_manager.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { promisify } from '@0xproject/utils'; -import { addHexPrefix, stripHexPrefix } from 'ethereumjs-util'; -import * as fs from 'fs'; -import { Collector } from 'istanbul'; -import * as _ from 'lodash'; -import { getLogger, levels, Logger, LogLevel } from 'loglevel'; -import * as mkdirp from 'mkdirp'; -import * as path from 'path'; - -import { AbstractArtifactAdapter } from './artifact_adapters/abstract_artifact_adapter'; -import { collectCoverageEntries } from './collect_coverage_entries'; -import { constants } from './constants'; -import { parseSourceMap } from './source_maps'; -import { - BranchCoverage, - BranchDescription, - BranchMap, - ContractData, - Coverage, - FnMap, - FunctionCoverage, - FunctionDescription, - LineColumn, - SingleFileSourceRange, - SourceRange, - StatementCoverage, - StatementDescription, - StatementMap, - TraceInfo, - TraceInfoExistingContract, - TraceInfoNewContract, -} from './types'; -import { utils } from './utils'; - -const mkdirpAsync = promisify<undefined>(mkdirp); - -export class CoverageManager { - private _artifactAdapter: AbstractArtifactAdapter; - private _logger: Logger; - private _traceInfos: TraceInfo[] = []; - private _getContractCodeAsync: (address: string) => Promise<string>; - private static _getSingleFileCoverageForTrace( - contractData: ContractData, - coveredPcs: number[], - pcToSourceRange: { [programCounter: number]: SourceRange }, - fileIndex: number, - ): Coverage { - const absoluteFileName = contractData.sources[fileIndex]; - const coverageEntriesDescription = collectCoverageEntries(contractData.sourceCodes[fileIndex]); - let sourceRanges = _.map(coveredPcs, coveredPc => pcToSourceRange[coveredPc]); - sourceRanges = _.compact(sourceRanges); // Some PC's don't map to a source range and we just ignore them. - // By default lodash does a shallow object comparasion. We JSON.stringify them and compare as strings. - sourceRanges = _.uniqBy(sourceRanges, s => JSON.stringify(s)); // We don't care if one PC was covered multiple times within a single transaction - sourceRanges = _.filter(sourceRanges, sourceRange => sourceRange.fileName === absoluteFileName); - const branchCoverage: BranchCoverage = {}; - const branchIds = _.keys(coverageEntriesDescription.branchMap); - for (const branchId of branchIds) { - const branchDescription = coverageEntriesDescription.branchMap[branchId]; - const isCoveredByBranchIndex = _.map(branchDescription.locations, location => - _.some(sourceRanges, range => utils.isRangeInside(range.location, location)), - ); - branchCoverage[branchId] = isCoveredByBranchIndex; - } - const statementCoverage: StatementCoverage = {}; - const statementIds = _.keys(coverageEntriesDescription.statementMap); - for (const statementId of statementIds) { - const statementDescription = coverageEntriesDescription.statementMap[statementId]; - const isCovered = _.some(sourceRanges, range => utils.isRangeInside(range.location, statementDescription)); - statementCoverage[statementId] = isCovered; - } - const functionCoverage: FunctionCoverage = {}; - const functionIds = _.keys(coverageEntriesDescription.fnMap); - for (const fnId of functionIds) { - const functionDescription = coverageEntriesDescription.fnMap[fnId]; - const isCovered = _.some(sourceRanges, range => - utils.isRangeInside(range.location, functionDescription.loc), - ); - functionCoverage[fnId] = isCovered; - } - // HACK: Solidity doesn't emit any opcodes that map back to modifiers with no args, that's why we map back to the - // function range and check if there is any covered statement within that range. - for (const modifierStatementId of coverageEntriesDescription.modifiersStatementIds) { - if (statementCoverage[modifierStatementId]) { - // Already detected as covered - continue; - } - const modifierDescription = coverageEntriesDescription.statementMap[modifierStatementId]; - const enclosingFunction = _.find(coverageEntriesDescription.fnMap, functionDescription => - utils.isRangeInside(modifierDescription, functionDescription.loc), - ) as FunctionDescription; - const isModifierCovered = _.some( - coverageEntriesDescription.statementMap, - (statementDescription: StatementDescription, statementId: number) => { - const isInsideTheModifierEnclosingFunction = utils.isRangeInside( - statementDescription, - enclosingFunction.loc, - ); - const isCovered = statementCoverage[statementId]; - return isInsideTheModifierEnclosingFunction && isCovered; - }, - ); - statementCoverage[modifierStatementId] = isModifierCovered; - } - const partialCoverage = { - [absoluteFileName]: { - ...coverageEntriesDescription, - l: {}, // It's able to derive it from statement coverage - path: absoluteFileName, - f: functionCoverage, - s: statementCoverage, - b: branchCoverage, - }, - }; - return partialCoverage; - } - private static _bytecodeToBytecodeRegex(bytecode: string): string { - const bytecodeRegex = bytecode - // Library linking placeholder: __ConvertLib____________________________ - .replace(/_.*_/, '.*') - // Last 86 characters is solidity compiler metadata that's different between compilations - .replace(/.{86}$/, '') - // Libraries contain their own address at the beginning of the code and it's impossible to know it in advance - .replace(/^0x730000000000000000000000000000000000000000/, '0x73........................................'); - return bytecodeRegex; - } - private static _getContractDataIfExists(contractsData: ContractData[], bytecode: string): ContractData | undefined { - if (!bytecode.startsWith('0x')) { - throw new Error(`0x hex prefix missing: ${bytecode}`); - } - const contractData = _.find(contractsData, contractDataCandidate => { - const bytecodeRegex = CoverageManager._bytecodeToBytecodeRegex(contractDataCandidate.bytecode); - const runtimeBytecodeRegex = CoverageManager._bytecodeToBytecodeRegex( - contractDataCandidate.runtimeBytecode, - ); - // We use that function to find by bytecode or runtimeBytecode. Those are quasi-random strings so - // collisions are practically impossible and it allows us to reuse that code - return !_.isNull(bytecode.match(bytecodeRegex)) || !_.isNull(bytecode.match(runtimeBytecodeRegex)); - }); - return contractData; - } - constructor( - artifactAdapter: AbstractArtifactAdapter, - getContractCodeAsync: (address: string) => Promise<string>, - isVerbose: boolean, - ) { - this._getContractCodeAsync = getContractCodeAsync; - this._artifactAdapter = artifactAdapter; - this._logger = getLogger('sol-cov'); - this._logger.setLevel(isVerbose ? levels.TRACE : levels.ERROR); - } - public appendTraceInfo(traceInfo: TraceInfo): void { - this._traceInfos.push(traceInfo); - } - public async writeCoverageAsync(): Promise<void> { - const finalCoverage = await this._computeCoverageAsync(); - const stringifiedCoverage = JSON.stringify(finalCoverage, null, '\t'); - await mkdirpAsync('coverage'); - fs.writeFileSync('coverage/coverage.json', stringifiedCoverage); - } - private async _computeCoverageAsync(): Promise<Coverage> { - const contractsData = await this._artifactAdapter.collectContractsDataAsync(); - const collector = new Collector(); - for (const traceInfo of this._traceInfos) { - if (traceInfo.address !== constants.NEW_CONTRACT) { - // Runtime transaction - const runtimeBytecode = (traceInfo as TraceInfoExistingContract).runtimeBytecode; - const contractData = CoverageManager._getContractDataIfExists(contractsData, runtimeBytecode); - if (_.isUndefined(contractData)) { - this._logger.warn(`Transaction to an unknown address: ${traceInfo.address}`); - continue; - } - const bytecodeHex = stripHexPrefix(runtimeBytecode); - const sourceMap = contractData.sourceMapRuntime; - const pcToSourceRange = parseSourceMap( - contractData.sourceCodes, - sourceMap, - bytecodeHex, - contractData.sources, - ); - for (let fileIndex = 0; fileIndex < contractData.sources.length; fileIndex++) { - const singleFileCoverageForTrace = CoverageManager._getSingleFileCoverageForTrace( - contractData, - traceInfo.coveredPcs, - pcToSourceRange, - fileIndex, - ); - collector.add(singleFileCoverageForTrace); - } - } else { - // Contract creation transaction - const bytecode = (traceInfo as TraceInfoNewContract).bytecode; - const contractData = CoverageManager._getContractDataIfExists(contractsData, bytecode); - if (_.isUndefined(contractData)) { - this._logger.warn(`Unknown contract creation transaction`); - continue; - } - const bytecodeHex = stripHexPrefix(bytecode); - const sourceMap = contractData.sourceMap; - const pcToSourceRange = parseSourceMap( - contractData.sourceCodes, - sourceMap, - bytecodeHex, - contractData.sources, - ); - for (let fileIndex = 0; fileIndex < contractData.sources.length; fileIndex++) { - const singleFileCoverageForTrace = CoverageManager._getSingleFileCoverageForTrace( - contractData, - traceInfo.coveredPcs, - pcToSourceRange, - fileIndex, - ); - collector.add(singleFileCoverageForTrace); - } - } - } - return collector.getFinalCoverage(); - } -} diff --git a/packages/sol-cov/src/coverage_subprovider.ts b/packages/sol-cov/src/coverage_subprovider.ts index ca7f4aed2..065a48434 100644 --- a/packages/sol-cov/src/coverage_subprovider.ts +++ b/packages/sol-cov/src/coverage_subprovider.ts @@ -1,32 +1,29 @@ -import { Callback, ErrorCallback, NextCallback, Subprovider } from '@0xproject/subproviders'; -import { BlockParam, CallData, JSONRPCRequestPayload, TransactionTrace, TxData } from 'ethereum-types'; -import * as fs from 'fs'; import * as _ from 'lodash'; -import { Lock } from 'semaphore-async-await'; import { AbstractArtifactAdapter } from './artifact_adapters/abstract_artifact_adapter'; -import { constants } from './constants'; -import { CoverageManager } from './coverage_manager'; -import { getTracesByContractAddress } from './trace'; -import { BlockParamLiteral, TraceInfoExistingContract, TraceInfoNewContract } from './types'; - -interface MaybeFakeTxData extends TxData { - isFakeTransaction?: boolean; -} - -// Because there is no notion of a call trace in the Ethereum rpc - we collect them in a rather non-obvious/hacky way. -// On each call - we create a snapshot, execute the call as a transaction, get the trace, revert the snapshot. -// That allows us to avoid influencing test behaviour. +import { collectCoverageEntries } from './collect_coverage_entries'; +import { SingleFileSubtraceHandler, TraceCollector } from './trace_collector'; +import { TraceInfoSubprovider } from './trace_info_subprovider'; +import { + BranchCoverage, + ContractData, + Coverage, + FunctionCoverage, + FunctionDescription, + SourceRange, + StatementCoverage, + StatementDescription, + Subtrace, + TraceInfo, +} from './types'; +import { utils } from './utils'; /** * This class implements the [web3-provider-engine](https://github.com/MetaMask/provider-engine) subprovider interface. - * It collects traces of all transactions that were sent and all calls that were executed through JSON RPC. + * It's used to compute your code coverage while running solidity tests. */ -export class CoverageSubprovider extends Subprovider { - // Lock is used to not accept normal transactions while doing call/snapshot magic because they'll be reverted later otherwise - private _lock: Lock; - private _coverageManager: CoverageManager; - private _defaultFromAddress: string; +export class CoverageSubprovider extends TraceInfoSubprovider { + private _coverageCollector: TraceCollector; /** * Instantiates a CoverageSubprovider instance * @param artifactAdapter Adapter for used artifacts format (0x, truffle, giveth, etc.) @@ -34,172 +31,110 @@ export class CoverageSubprovider extends Subprovider { * @param isVerbose If true, we will log any unknown transactions. Otherwise we will ignore them */ constructor(artifactAdapter: AbstractArtifactAdapter, defaultFromAddress: string, isVerbose: boolean = true) { - super(); - this._lock = new Lock(); - this._defaultFromAddress = defaultFromAddress; - this._coverageManager = new CoverageManager(artifactAdapter, this._getContractCodeAsync.bind(this), isVerbose); + const traceCollectionSubproviderConfig = { + shouldCollectTransactionTraces: true, + shouldCollectGasEstimateTraces: true, + shouldCollectCallTraces: true, + }; + super(defaultFromAddress, traceCollectionSubproviderConfig); + this._coverageCollector = new TraceCollector(artifactAdapter, isVerbose, coverageHandler); + } + protected async _handleTraceInfoAsync(traceInfo: TraceInfo): Promise<void> { + await this._coverageCollector.computeSingleTraceCoverageAsync(traceInfo); } /** * Write the test coverage results to a file in Istanbul format. */ public async writeCoverageAsync(): Promise<void> { - await this._coverageManager.writeCoverageAsync(); + await this._coverageCollector.writeOutputAsync(); } - /** - * This method conforms to the web3-provider-engine interface. - * It is called internally by the ProviderEngine when it is this subproviders - * turn to handle a JSON RPC request. - * @param payload JSON RPC payload - * @param next Callback to call if this subprovider decides not to handle the request - * @param end Callback to call if subprovider handled the request and wants to pass back the request. - */ - // tslint:disable-next-line:prefer-function-over-method async-suffix - public async handleRequest(payload: JSONRPCRequestPayload, next: NextCallback, end: ErrorCallback): Promise<void> { - switch (payload.method) { - case 'eth_sendTransaction': - const txData = payload.params[0]; - next(this._onTransactionSentAsync.bind(this, txData)); - return; - - case 'eth_call': - const callData = payload.params[0]; - const blockNumber = payload.params[1]; - next(this._onCallExecutedAsync.bind(this, callData, blockNumber)); - return; +} - default: - next(); - return; - } - } - private async _onTransactionSentAsync( - txData: MaybeFakeTxData, - err: Error | null, - txHash: string | undefined, - cb: Callback, - ): Promise<void> { - if (!txData.isFakeTransaction) { - // This transaction is a usual ttransaction. Not a call executed as one. - // And we don't want it to be executed within a snapshotting period - await this._lock.acquire(); - } - if (_.isNull(err)) { - const toAddress = _.isUndefined(txData.to) || txData.to === '0x0' ? constants.NEW_CONTRACT : txData.to; - await this._recordTxTraceAsync(toAddress, txData.data, txHash as string); - } else { - const payload = { - method: 'eth_getBlockByNumber', - params: [BlockParamLiteral.Latest, true], - }; - const jsonRPCResponsePayload = await this.emitPayloadAsync(payload); - const transactions = jsonRPCResponsePayload.result.transactions; - for (const transaction of transactions) { - const toAddress = _.isUndefined(txData.to) || txData.to === '0x0' ? constants.NEW_CONTRACT : txData.to; - await this._recordTxTraceAsync(toAddress, transaction.data, transaction.hash); - } - } - if (!txData.isFakeTransaction) { - // This transaction is a usual ttransaction. Not a call executed as one. - // And we don't want it to be executed within a snapshotting period - this._lock.release(); - } - cb(); +/** + * Computed partial coverage for a single file & subtrace. + * @param contractData Contract metadata (source, srcMap, bytecode) + * @param subtrace A subset of a transcation/call trace that was executed within that contract + * @param pcToSourceRange A mapping from program counters to source ranges + * @param fileIndex Index of a file to compute coverage for + * @return Partial istanbul coverage for that file & subtrace + */ +export const coverageHandler: SingleFileSubtraceHandler = ( + contractData: ContractData, + subtrace: Subtrace, + pcToSourceRange: { [programCounter: number]: SourceRange }, + fileIndex: number, +): Coverage => { + const absoluteFileName = contractData.sources[fileIndex]; + const coverageEntriesDescription = collectCoverageEntries(contractData.sourceCodes[fileIndex]); + let sourceRanges = _.map(subtrace, structLog => pcToSourceRange[structLog.pc]); + sourceRanges = _.compact(sourceRanges); // Some PC's don't map to a source range and we just ignore them. + // By default lodash does a shallow object comparasion. We JSON.stringify them and compare as strings. + sourceRanges = _.uniqBy(sourceRanges, s => JSON.stringify(s)); // We don't care if one PC was covered multiple times within a single transaction + sourceRanges = _.filter(sourceRanges, sourceRange => sourceRange.fileName === absoluteFileName); + const branchCoverage: BranchCoverage = {}; + const branchIds = _.keys(coverageEntriesDescription.branchMap); + for (const branchId of branchIds) { + const branchDescription = coverageEntriesDescription.branchMap[branchId]; + const isBranchCoveredByBranchIndex = _.map(branchDescription.locations, location => { + const isBranchCovered = _.some(sourceRanges, range => utils.isRangeInside(range.location, location)); + const timesBranchCovered = Number(isBranchCovered); + return timesBranchCovered; + }); + branchCoverage[branchId] = isBranchCoveredByBranchIndex; } - private async _onCallExecutedAsync( - callData: Partial<CallData>, - blockNumber: BlockParam, - err: Error | null, - callResult: string, - cb: Callback, - ): Promise<void> { - await this._recordCallTraceAsync(callData, blockNumber); - cb(); + const statementCoverage: StatementCoverage = {}; + const statementIds = _.keys(coverageEntriesDescription.statementMap); + for (const statementId of statementIds) { + const statementDescription = coverageEntriesDescription.statementMap[statementId]; + const isStatementCovered = _.some(sourceRanges, range => + utils.isRangeInside(range.location, statementDescription), + ); + const timesStatementCovered = Number(isStatementCovered); + statementCoverage[statementId] = timesStatementCovered; } - private async _recordTxTraceAsync(address: string, data: string | undefined, txHash: string): Promise<void> { - let payload = { - method: 'debug_traceTransaction', - params: [txHash, { disableMemory: true, disableStack: false, disableStorage: true }], - }; - let jsonRPCResponsePayload = await this.emitPayloadAsync(payload); - const trace: TransactionTrace = jsonRPCResponsePayload.result; - const tracesByContractAddress = getTracesByContractAddress(trace.structLogs, address); - const subcallAddresses = _.keys(tracesByContractAddress); - if (address === constants.NEW_CONTRACT) { - for (const subcallAddress of subcallAddresses) { - let traceInfo: TraceInfoNewContract | TraceInfoExistingContract; - if (subcallAddress === 'NEW_CONTRACT') { - const traceForThatSubcall = tracesByContractAddress[subcallAddress]; - const coveredPcs = _.map(traceForThatSubcall, log => log.pc); - traceInfo = { - coveredPcs, - txHash, - address: constants.NEW_CONTRACT, - bytecode: data as string, - }; - } else { - payload = { method: 'eth_getCode', params: [subcallAddress, BlockParamLiteral.Latest] }; - jsonRPCResponsePayload = await this.emitPayloadAsync(payload); - const runtimeBytecode = jsonRPCResponsePayload.result; - const traceForThatSubcall = tracesByContractAddress[subcallAddress]; - const coveredPcs = _.map(traceForThatSubcall, log => log.pc); - traceInfo = { - coveredPcs, - txHash, - address: subcallAddress, - runtimeBytecode, - }; - } - this._coverageManager.appendTraceInfo(traceInfo); - } - } else { - for (const subcallAddress of subcallAddresses) { - payload = { method: 'eth_getCode', params: [subcallAddress, BlockParamLiteral.Latest] }; - jsonRPCResponsePayload = await this.emitPayloadAsync(payload); - const runtimeBytecode = jsonRPCResponsePayload.result; - const traceForThatSubcall = tracesByContractAddress[subcallAddress]; - const coveredPcs = _.map(traceForThatSubcall, log => log.pc); - const traceInfo: TraceInfoExistingContract = { - coveredPcs, - txHash, - address: subcallAddress, - runtimeBytecode, - }; - this._coverageManager.appendTraceInfo(traceInfo); - } - } + const functionCoverage: FunctionCoverage = {}; + const functionIds = _.keys(coverageEntriesDescription.fnMap); + for (const fnId of functionIds) { + const functionDescription = coverageEntriesDescription.fnMap[fnId]; + const isFunctionCovered = _.some(sourceRanges, range => + utils.isRangeInside(range.location, functionDescription.loc), + ); + const timesFunctionCovered = Number(isFunctionCovered); + functionCoverage[fnId] = timesFunctionCovered; } - private async _recordCallTraceAsync(callData: Partial<CallData>, blockNumber: BlockParam): Promise<void> { - // We don't want other transactions to be exeucted during snashotting period, that's why we lock the - // transaction execution for all transactions except our fake ones. - await this._lock.acquire(); - const snapshotId = Number((await this.emitPayloadAsync({ method: 'evm_snapshot' })).result); - const fakeTxData: MaybeFakeTxData = { - isFakeTransaction: true, // This transaction (and only it) is allowed to come through when the lock is locked - ...callData, - from: callData.from || this._defaultFromAddress, - }; - try { - await this.emitPayloadAsync({ - method: 'eth_sendTransaction', - params: [fakeTxData], - }); - } catch (err) { - // Even if this transaction failed - we've already recorded it's trace. - } - const jsonRPCResponse = await this.emitPayloadAsync({ method: 'evm_revert', params: [snapshotId] }); - this._lock.release(); - const didRevert = jsonRPCResponse.result; - if (!didRevert) { - throw new Error('Failed to revert the snapshot'); + // HACK: Solidity doesn't emit any opcodes that map back to modifiers with no args, that's why we map back to the + // function range and check if there is any covered statement within that range. + for (const modifierStatementId of coverageEntriesDescription.modifiersStatementIds) { + if (statementCoverage[modifierStatementId]) { + // Already detected as covered + continue; } + const modifierDescription = coverageEntriesDescription.statementMap[modifierStatementId]; + const enclosingFunction = _.find(coverageEntriesDescription.fnMap, functionDescription => + utils.isRangeInside(modifierDescription, functionDescription.loc), + ) as FunctionDescription; + const isModifierCovered = _.some( + coverageEntriesDescription.statementMap, + (statementDescription: StatementDescription, statementId: number) => { + const isInsideTheModifierEnclosingFunction = utils.isRangeInside( + statementDescription, + enclosingFunction.loc, + ); + const isCovered = statementCoverage[statementId]; + return isInsideTheModifierEnclosingFunction && isCovered; + }, + ); + const timesModifierCovered = Number(isModifierCovered); + statementCoverage[modifierStatementId] = timesModifierCovered; } - private async _getContractCodeAsync(address: string): Promise<string> { - const payload = { - method: 'eth_getCode', - params: [address, BlockParamLiteral.Latest], - }; - const jsonRPCResponsePayload = await this.emitPayloadAsync(payload); - const contractCode: string = jsonRPCResponsePayload.result; - return contractCode; - } -} + const partialCoverage = { + [absoluteFileName]: { + ...coverageEntriesDescription, + path: absoluteFileName, + f: functionCoverage, + s: statementCoverage, + b: branchCoverage, + }, + }; + return partialCoverage; +}; diff --git a/packages/sol-cov/src/index.ts b/packages/sol-cov/src/index.ts index 7a2afbe80..003a27374 100644 --- a/packages/sol-cov/src/index.ts +++ b/packages/sol-cov/src/index.ts @@ -1,4 +1,7 @@ export { CoverageSubprovider } from './coverage_subprovider'; +// HACK: ProfilerSubprovider is a hacky way to do profiling using coverage tools. Not production ready +export { ProfilerSubprovider } from './profiler_subprovider'; +export { RevertTraceSubprovider } from './revert_trace_subprovider'; export { SolCompilerArtifactAdapter } from './artifact_adapters/sol_compiler_artifact_adapter'; export { TruffleArtifactAdapter } from './artifact_adapters/truffle_artifact_adapter'; export { AbstractArtifactAdapter } from './artifact_adapters/abstract_artifact_adapter'; diff --git a/packages/sol-cov/src/profiler_subprovider.ts b/packages/sol-cov/src/profiler_subprovider.ts new file mode 100644 index 000000000..9f98da524 --- /dev/null +++ b/packages/sol-cov/src/profiler_subprovider.ts @@ -0,0 +1,91 @@ +import * as _ from 'lodash'; + +import { AbstractArtifactAdapter } from './artifact_adapters/abstract_artifact_adapter'; +import { collectCoverageEntries } from './collect_coverage_entries'; +import { SingleFileSubtraceHandler, TraceCollector } from './trace_collector'; +import { TraceInfoSubprovider } from './trace_info_subprovider'; +import { ContractData, Coverage, SourceRange, Subtrace, TraceInfo } from './types'; +import { utils } from './utils'; + +/** + * This class implements the [web3-provider-engine](https://github.com/MetaMask/provider-engine) subprovider interface. + * ProfilerSubprovider is used to profile Solidity code while running tests. + */ +export class ProfilerSubprovider extends TraceInfoSubprovider { + private _profilerCollector: TraceCollector; + /** + * Instantiates a ProfilerSubprovider instance + * @param artifactAdapter Adapter for used artifacts format (0x, truffle, giveth, etc.) + * @param defaultFromAddress default from address to use when sending transactions + * @param isVerbose If true, we will log any unknown transactions. Otherwise we will ignore them + */ + constructor(artifactAdapter: AbstractArtifactAdapter, defaultFromAddress: string, isVerbose: boolean = true) { + const traceCollectionSubproviderConfig = { + shouldCollectTransactionTraces: true, + shouldCollectGasEstimateTraces: false, + shouldCollectCallTraces: false, + }; + super(defaultFromAddress, traceCollectionSubproviderConfig); + this._profilerCollector = new TraceCollector(artifactAdapter, isVerbose, profilerHandler); + } + protected async _handleTraceInfoAsync(traceInfo: TraceInfo): Promise<void> { + await this._profilerCollector.computeSingleTraceCoverageAsync(traceInfo); + } + /** + * Write the test profiler results to a file in Istanbul format. + */ + public async writeProfilerOutputAsync(): Promise<void> { + await this._profilerCollector.writeOutputAsync(); + } +} + +/** + * Computed partial coverage for a single file & subtrace for the purposes of + * gas profiling. + * @param contractData Contract metadata (source, srcMap, bytecode) + * @param subtrace A subset of a transcation/call trace that was executed within that contract + * @param pcToSourceRange A mapping from program counters to source ranges + * @param fileIndex Index of a file to compute coverage for + * @return Partial istanbul coverage for that file & subtrace + */ +export const profilerHandler: SingleFileSubtraceHandler = ( + contractData: ContractData, + subtrace: Subtrace, + pcToSourceRange: { [programCounter: number]: SourceRange }, + fileIndex: number, +): Coverage => { + const absoluteFileName = contractData.sources[fileIndex]; + const profilerEntriesDescription = collectCoverageEntries(contractData.sourceCodes[fileIndex]); + const gasConsumedByStatement: { [statementId: string]: number } = {}; + const statementIds = _.keys(profilerEntriesDescription.statementMap); + for (const statementId of statementIds) { + const statementDescription = profilerEntriesDescription.statementMap[statementId]; + const totalGasCost = _.sum( + _.map(subtrace, structLog => { + const sourceRange = pcToSourceRange[structLog.pc]; + if (_.isUndefined(sourceRange)) { + return 0; + } + if (sourceRange.fileName !== absoluteFileName) { + return 0; + } + if (utils.isRangeInside(sourceRange.location, statementDescription)) { + return structLog.gasCost; + } else { + return 0; + } + }), + ); + gasConsumedByStatement[statementId] = totalGasCost; + } + const partialProfilerOutput = { + [absoluteFileName]: { + ...profilerEntriesDescription, + path: absoluteFileName, + f: {}, // I's meaningless in profiling context + s: gasConsumedByStatement, + b: {}, // I's meaningless in profiling context + }, + }; + return partialProfilerOutput; +}; diff --git a/packages/sol-cov/src/revert_trace.ts b/packages/sol-cov/src/revert_trace.ts new file mode 100644 index 000000000..a78d1afa8 --- /dev/null +++ b/packages/sol-cov/src/revert_trace.ts @@ -0,0 +1,90 @@ +import { logUtils } from '@0xproject/utils'; +import { OpCode, StructLog } from 'ethereum-types'; + +import * as _ from 'lodash'; + +import { EvmCallStack } from './types'; +import { utils } from './utils'; + +export function getRevertTrace(structLogs: StructLog[], startAddress: string): EvmCallStack { + const evmCallStack: EvmCallStack = []; + const addressStack = [startAddress]; + if (_.isEmpty(structLogs)) { + return []; + } + const normalizedStructLogs = utils.normalizeStructLogs(structLogs); + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < normalizedStructLogs.length; i++) { + const structLog = normalizedStructLogs[i]; + if (structLog.depth !== addressStack.length - 1) { + throw new Error("Malformed trace. Trace depth doesn't match call stack depth"); + } + // After that check we have a guarantee that call stack is never empty + // If it would: callStack.length - 1 === structLog.depth === -1 + // That means that we can always safely pop from it + + if (utils.isCallLike(structLog.op)) { + const currentAddress = _.last(addressStack) as string; + const jumpAddressOffset = 1; + const newAddress = utils.getAddressFromStackEntry( + structLog.stack[structLog.stack.length - jumpAddressOffset - 1], + ); + + // Sometimes calls don't change the execution context (current address). When we do a transfer to an + // externally owned account - it does the call and immediately returns because there is no fallback + // function. We manually check if the call depth had changed to handle that case. + const nextStructLog = normalizedStructLogs[i + 1]; + if (nextStructLog.depth !== structLog.depth) { + addressStack.push(newAddress); + evmCallStack.push({ + address: currentAddress, + structLog, + }); + } + } else if (utils.isEndOpcode(structLog.op) && structLog.op !== OpCode.Revert) { + // Just like with calls, sometimes returns/stops don't change the execution context (current address). + const nextStructLog = normalizedStructLogs[i + 1]; + if (_.isUndefined(nextStructLog) || nextStructLog.depth !== structLog.depth) { + evmCallStack.pop(); + addressStack.pop(); + } + if (structLog.op === OpCode.SelfDestruct) { + // After contract execution, we look at all sub-calls to external contracts, and for each one, fetch + // the bytecode and compute the coverage for the call. If the contract is destroyed with a call + // to `selfdestruct`, we are unable to fetch it's bytecode and compute coverage. + // TODO: Refactor this logic to fetch the sub-called contract bytecode before the selfdestruct is called + // in order to handle this edge-case. + logUtils.warn( + "Detected a selfdestruct. Sol-cov currently doesn't support that scenario. We'll just skip the trace part for a destructed contract", + ); + } + } else if (structLog.op === OpCode.Revert) { + evmCallStack.push({ + address: _.last(addressStack) as string, + structLog, + }); + return evmCallStack; + } else if (structLog.op === OpCode.Create) { + // TODO: Extract the new contract address from the stack and handle that scenario + logUtils.warn( + "Detected a contract created from within another contract. Sol-cov currently doesn't support that scenario. We'll just skip that trace", + ); + return []; + } else { + if (structLog !== _.last(normalizedStructLogs)) { + const nextStructLog = normalizedStructLogs[i + 1]; + if (nextStructLog.depth === structLog.depth) { + continue; + } else if (nextStructLog.depth === structLog.depth - 1) { + addressStack.pop(); + } else { + throw new Error('Malformed trace. Unexpected call depth change'); + } + } + } + } + if (evmCallStack.length !== 0) { + logUtils.warn('Malformed trace. Call stack non empty at the end. (probably out of gas)'); + } + return []; +} diff --git a/packages/sol-cov/src/revert_trace_subprovider.ts b/packages/sol-cov/src/revert_trace_subprovider.ts new file mode 100644 index 000000000..b1d4da10c --- /dev/null +++ b/packages/sol-cov/src/revert_trace_subprovider.ts @@ -0,0 +1,114 @@ +import { stripHexPrefix } from 'ethereumjs-util'; +import * as _ from 'lodash'; +import { getLogger, levels, Logger } from 'loglevel'; + +import { AbstractArtifactAdapter } from './artifact_adapters/abstract_artifact_adapter'; +import { constants } from './constants'; +import { getRevertTrace } from './revert_trace'; +import { parseSourceMap } from './source_maps'; +import { TraceCollectionSubprovider } from './trace_collection_subprovider'; +import { ContractData, EvmCallStack, SourceRange } from './types'; +import { utils } from './utils'; + +/** + * This class implements the [web3-provider-engine](https://github.com/MetaMask/provider-engine) subprovider interface. + * It is used to report call stack traces whenever a revert occurs. + */ +export class RevertTraceSubprovider extends TraceCollectionSubprovider { + // Lock is used to not accept normal transactions while doing call/snapshot magic because they'll be reverted later otherwise + private _contractsData!: ContractData[]; + private _artifactAdapter: AbstractArtifactAdapter; + private _logger: Logger; + + /** + * Instantiates a RevertTraceSubprovider instance + * @param artifactAdapter Adapter for used artifacts format (0x, truffle, giveth, etc.) + * @param defaultFromAddress default from address to use when sending transactions + * @param isVerbose If true, we will log any unknown transactions. Otherwise we will ignore them + */ + constructor(artifactAdapter: AbstractArtifactAdapter, defaultFromAddress: string, isVerbose: boolean = true) { + const traceCollectionSubproviderConfig = { + shouldCollectTransactionTraces: true, + shouldCollectGasEstimateTraces: true, + shouldCollectCallTraces: true, + }; + super(defaultFromAddress, traceCollectionSubproviderConfig); + this._artifactAdapter = artifactAdapter; + this._logger = getLogger('sol-cov'); + this._logger.setLevel(isVerbose ? levels.TRACE : levels.ERROR); + } + // tslint:disable-next-line:no-unused-variable + protected async _recordTxTraceAsync(address: string, data: string | undefined, txHash: string): Promise<void> { + await this._web3Wrapper.awaitTransactionMinedAsync(txHash, 0); + const trace = await this._web3Wrapper.getTransactionTraceAsync(txHash, { + disableMemory: true, + disableStack: false, + disableStorage: true, + }); + const evmCallStack = getRevertTrace(trace.structLogs, address); + if (evmCallStack.length > 0) { + // if getRevertTrace returns a call stack it means there was a + // revert. + await this._printStackTraceAsync(evmCallStack); + } + } + private async _printStackTraceAsync(evmCallStack: EvmCallStack): Promise<void> { + const sourceRanges: SourceRange[] = []; + if (_.isUndefined(this._contractsData)) { + this._contractsData = await this._artifactAdapter.collectContractsDataAsync(); + } + for (const evmCallStackEntry of evmCallStack) { + const isContractCreation = evmCallStackEntry.address === constants.NEW_CONTRACT; + if (isContractCreation) { + this._logger.error('Contract creation not supported'); + continue; + } + const bytecode = await this._web3Wrapper.getContractCodeAsync(evmCallStackEntry.address); + const contractData = utils.getContractDataIfExists(this._contractsData, bytecode); + if (_.isUndefined(contractData)) { + const errMsg = isContractCreation + ? `Unknown contract creation transaction` + : `Transaction to an unknown address: ${evmCallStackEntry.address}`; + this._logger.warn(errMsg); + continue; + } + const bytecodeHex = stripHexPrefix(bytecode); + const sourceMap = isContractCreation ? contractData.sourceMap : contractData.sourceMapRuntime; + const pcToSourceRange = parseSourceMap( + contractData.sourceCodes, + sourceMap, + bytecodeHex, + contractData.sources, + ); + // tslint:disable-next-line:no-unnecessary-initializer + let sourceRange: SourceRange | undefined = undefined; + let pc = evmCallStackEntry.structLog.pc; + // Sometimes there is not a mapping for this pc (e.g. if the revert + // actually happens in assembly). In that case, we want to keep + // searching backwards by decrementing the pc until we find a + // mapped source range. + while (_.isUndefined(sourceRange)) { + sourceRange = pcToSourceRange[pc]; + pc -= 1; + if (pc <= 0) { + this._logger.warn( + `could not find matching sourceRange for structLog: ${evmCallStackEntry.structLog}`, + ); + continue; + } + } + sourceRanges.push(sourceRange); + } + if (sourceRanges.length > 0) { + this._logger.error('\n\nStack trace for REVERT:\n'); + _.forEach(_.reverse(sourceRanges), sourceRange => { + this._logger.error( + `${sourceRange.fileName}:${sourceRange.location.start.line}:${sourceRange.location.start.column}`, + ); + }); + this._logger.error('\n'); + } else { + this._logger.error('Could not determine stack trace'); + } + } +} diff --git a/packages/sol-cov/src/source_maps.ts b/packages/sol-cov/src/source_maps.ts index 694171442..f9503e16c 100644 --- a/packages/sol-cov/src/source_maps.ts +++ b/packages/sol-cov/src/source_maps.ts @@ -38,10 +38,10 @@ export function parseSourceMap( const pcToInstructionIndex: { [programCounter: number]: number } = getPcToInstructionIndexMapping(bytecode); const locationByOffsetByFileIndex = _.map(sourceCodes, getLocationByOffset); const entries = srcMap.split(';'); - const parsedEntries: SourceLocation[] = []; let lastParsedEntry: SourceLocation = {} as any; const instructionIndexToSourceRange: { [instructionIndex: number]: SourceRange } = {}; _.each(entries, (entry: string, i: number) => { + // tslint:disable-next-line:no-unused-variable const [instructionIndexStrIfExists, lengthStrIfExists, fileIndexStrIfExists, jumpTypeStrIfExists] = entry.split( ':', ); diff --git a/packages/sol-cov/src/trace.ts b/packages/sol-cov/src/trace.ts index 1c21c8e5c..635019fc0 100644 --- a/packages/sol-cov/src/trace.ts +++ b/packages/sol-cov/src/trace.ts @@ -1,26 +1,25 @@ -import { addressUtils, BigNumber, logUtils } from '@0xproject/utils'; -import { OpCode, StructLog, TransactionTrace } from 'ethereum-types'; -import { addHexPrefix, stripHexPrefix } from 'ethereumjs-util'; -import * as fs from 'fs'; +import { logUtils } from '@0xproject/utils'; +import { OpCode, StructLog } from 'ethereum-types'; import * as _ from 'lodash'; +import { utils } from './utils'; + export interface TraceByContractAddress { [contractAddress: string]: StructLog[]; } -function getAddressFromStackEntry(stackEntry: string): string { - const hexBase = 16; - return addressUtils.padZeros(new BigNumber(addHexPrefix(stackEntry)).toString(hexBase)); -} - export function getTracesByContractAddress(structLogs: StructLog[], startAddress: string): TraceByContractAddress { const traceByContractAddress: TraceByContractAddress = {}; let currentTraceSegment = []; - const callStack = [startAddress]; + const addressStack = [startAddress]; + if (_.isEmpty(structLogs)) { + return traceByContractAddress; + } + const normalizedStructLogs = utils.normalizeStructLogs(structLogs); // tslint:disable-next-line:prefer-for-of - for (let i = 0; i < structLogs.length; i++) { - const structLog = structLogs[i]; - if (structLog.depth !== callStack.length - 1) { + for (let i = 0; i < normalizedStructLogs.length; i++) { + const structLog = normalizedStructLogs[i]; + if (structLog.depth !== addressStack.length - 1) { throw new Error("Malformed trace. Trace depth doesn't match call stack depth"); } // After that check we have a guarantee that call stack is never empty @@ -28,36 +27,26 @@ export function getTracesByContractAddress(structLogs: StructLog[], startAddress // That means that we can always safely pop from it currentTraceSegment.push(structLog); - const isCallLike = _.includes( - [OpCode.CallCode, OpCode.StaticCall, OpCode.Call, OpCode.DelegateCall], - structLog.op, - ); - const isEndOpcode = _.includes( - [OpCode.Return, OpCode.Stop, OpCode.Revert, OpCode.Invalid, OpCode.SelfDestruct], - structLog.op, - ); - if (isCallLike) { - const currentAddress = _.last(callStack) as string; + if (utils.isCallLike(structLog.op)) { + const currentAddress = _.last(addressStack) as string; const jumpAddressOffset = 1; - const newAddress = getAddressFromStackEntry( + const newAddress = utils.getAddressFromStackEntry( structLog.stack[structLog.stack.length - jumpAddressOffset - 1], ); - if (structLog === _.last(structLogs)) { - throw new Error('Malformed trace. CALL-like opcode can not be the last one'); - } + // Sometimes calls don't change the execution context (current address). When we do a transfer to an // externally owned account - it does the call and immediately returns because there is no fallback // function. We manually check if the call depth had changed to handle that case. - const nextStructLog = structLogs[i + 1]; + const nextStructLog = normalizedStructLogs[i + 1]; if (nextStructLog.depth !== structLog.depth) { - callStack.push(newAddress); + addressStack.push(newAddress); traceByContractAddress[currentAddress] = (traceByContractAddress[currentAddress] || []).concat( currentTraceSegment, ); currentTraceSegment = []; } - } else if (isEndOpcode) { - const currentAddress = callStack.pop() as string; + } else if (utils.isEndOpcode(structLog.op)) { + const currentAddress = addressStack.pop() as string; traceByContractAddress[currentAddress] = (traceByContractAddress[currentAddress] || []).concat( currentTraceSegment, ); @@ -79,12 +68,12 @@ export function getTracesByContractAddress(structLogs: StructLog[], startAddress ); return traceByContractAddress; } else { - if (structLog !== _.last(structLogs)) { - const nextStructLog = structLogs[i + 1]; + if (structLog !== _.last(normalizedStructLogs)) { + const nextStructLog = normalizedStructLogs[i + 1]; if (nextStructLog.depth === structLog.depth) { continue; } else if (nextStructLog.depth === structLog.depth - 1) { - const currentAddress = callStack.pop() as string; + const currentAddress = addressStack.pop() as string; traceByContractAddress[currentAddress] = (traceByContractAddress[currentAddress] || []).concat( currentTraceSegment, ); @@ -95,11 +84,16 @@ export function getTracesByContractAddress(structLogs: StructLog[], startAddress } } } - if (callStack.length !== 0) { - throw new Error('Malformed trace. Call stack non empty at the end'); + if (addressStack.length !== 0) { + logUtils.warn('Malformed trace. Call stack non empty at the end'); } if (currentTraceSegment.length !== 0) { - throw new Error('Malformed trace. Current trace segment non empty at the end'); + const currentAddress = addressStack.pop() as string; + traceByContractAddress[currentAddress] = (traceByContractAddress[currentAddress] || []).concat( + currentTraceSegment, + ); + currentTraceSegment = []; + logUtils.warn('Malformed trace. Current trace segment non empty at the end'); } return traceByContractAddress; } diff --git a/packages/sol-cov/src/trace_collection_subprovider.ts b/packages/sol-cov/src/trace_collection_subprovider.ts new file mode 100644 index 000000000..9866472b9 --- /dev/null +++ b/packages/sol-cov/src/trace_collection_subprovider.ts @@ -0,0 +1,185 @@ +import { BlockchainLifecycle } from '@0xproject/dev-utils'; +import { Callback, ErrorCallback, NextCallback, Subprovider } from '@0xproject/subproviders'; +import { Web3Wrapper } from '@0xproject/web3-wrapper'; +import { CallData, JSONRPCRequestPayload, Provider, TxData } from 'ethereum-types'; +import * as _ from 'lodash'; +import { Lock } from 'semaphore-async-await'; + +import { constants } from './constants'; +import { BlockParamLiteral } from './types'; + +interface MaybeFakeTxData extends TxData { + isFakeTransaction?: boolean; +} + +const BLOCK_GAS_LIMIT = 6000000; + +export interface TraceCollectionSubproviderConfig { + shouldCollectTransactionTraces: boolean; + shouldCollectCallTraces: boolean; + shouldCollectGasEstimateTraces: boolean; +} + +// Because there is no notion of a call trace in the Ethereum rpc - we collect them in a rather non-obvious/hacky way. +// On each call - we create a snapshot, execute the call as a transaction, get the trace, revert the snapshot. +// That allows us to avoid influencing test behaviour. + +/** + * This class implements the [web3-provider-engine](https://github.com/MetaMask/provider-engine) subprovider interface. + * It collects traces of all transactions that were sent and all calls that were executed through JSON RPC. It must + * be extended by implementing the _recordTxTraceAsync method which is called for every transaction. + */ +export abstract class TraceCollectionSubprovider extends Subprovider { + protected _web3Wrapper!: Web3Wrapper; + // Lock is used to not accept normal transactions while doing call/snapshot magic because they'll be reverted later otherwise + private _lock = new Lock(); + private _defaultFromAddress: string; + private _isEnabled = true; + private _config: TraceCollectionSubproviderConfig; + /** + * Instantiates a TraceCollectionSubprovider instance + * @param defaultFromAddress default from address to use when sending transactions + */ + constructor(defaultFromAddress: string, config: TraceCollectionSubproviderConfig) { + super(); + this._defaultFromAddress = defaultFromAddress; + this._config = config; + } + /** + * Starts trace collection + */ + public start(): void { + this._isEnabled = true; + } + /** + * Stops trace collection + */ + public stop(): void { + this._isEnabled = false; + } + /** + * This method conforms to the web3-provider-engine interface. + * It is called internally by the ProviderEngine when it is this subproviders + * turn to handle a JSON RPC request. + * @param payload JSON RPC payload + * @param next Callback to call if this subprovider decides not to handle the request + * @param end Callback to call if subprovider handled the request and wants to pass back the request. + */ + // tslint:disable-next-line:prefer-function-over-method async-suffix + public async handleRequest(payload: JSONRPCRequestPayload, next: NextCallback, _end: ErrorCallback): Promise<void> { + if (this._isEnabled) { + switch (payload.method) { + case 'eth_sendTransaction': + if (!this._config.shouldCollectTransactionTraces) { + next(); + } else { + const txData = payload.params[0]; + next(this._onTransactionSentAsync.bind(this, txData)); + } + return; + + case 'eth_call': + if (!this._config.shouldCollectCallTraces) { + next(); + } else { + const callData = payload.params[0]; + next(this._onCallOrGasEstimateExecutedAsync.bind(this, callData)); + } + return; + + case 'eth_estimateGas': + if (!this._config.shouldCollectGasEstimateTraces) { + next(); + } else { + const estimateGasData = payload.params[0]; + next(this._onCallOrGasEstimateExecutedAsync.bind(this, estimateGasData)); + } + return; + + default: + next(); + return; + } + } else { + next(); + return; + } + } + /** + * Set's the subprovider's engine to the ProviderEngine it is added to. + * This is only called within the ProviderEngine source code, do not call + * directly. + */ + public setEngine(engine: Provider): void { + super.setEngine(engine); + this._web3Wrapper = new Web3Wrapper(engine); + } + protected abstract async _recordTxTraceAsync( + address: string, + data: string | undefined, + txHash: string, + ): Promise<void>; + private async _onTransactionSentAsync( + txData: MaybeFakeTxData, + err: Error | null, + txHash: string | undefined, + cb: Callback, + ): Promise<void> { + if (!txData.isFakeTransaction) { + // This transaction is a usual transaction. Not a call executed as one. + // And we don't want it to be executed within a snapshotting period + await this._lock.acquire(); + } + const NULL_ADDRESS = '0x0'; + if (_.isNull(err)) { + const toAddress = + _.isUndefined(txData.to) || txData.to === NULL_ADDRESS ? constants.NEW_CONTRACT : txData.to; + await this._recordTxTraceAsync(toAddress, txData.data, txHash as string); + } else { + const latestBlock = await this._web3Wrapper.getBlockWithTransactionDataAsync(BlockParamLiteral.Latest); + const transactions = latestBlock.transactions; + for (const transaction of transactions) { + const toAddress = + _.isUndefined(txData.to) || txData.to === NULL_ADDRESS ? constants.NEW_CONTRACT : txData.to; + await this._recordTxTraceAsync(toAddress, transaction.input, transaction.hash); + } + } + if (!txData.isFakeTransaction) { + // This transaction is a usual transaction. Not a call executed as one. + // And we don't want it to be executed within a snapshotting period + this._lock.release(); + } + cb(); + } + private async _onCallOrGasEstimateExecutedAsync( + callData: Partial<CallData>, + _err: Error | null, + _callResult: string, + cb: Callback, + ): Promise<void> { + await this._recordCallOrGasEstimateTraceAsync(callData); + cb(); + } + private async _recordCallOrGasEstimateTraceAsync(callData: Partial<CallData>): Promise<void> { + // We don't want other transactions to be exeucted during snashotting period, that's why we lock the + // transaction execution for all transactions except our fake ones. + await this._lock.acquire(); + const blockchainLifecycle = new BlockchainLifecycle(this._web3Wrapper); + await blockchainLifecycle.startAsync(); + const fakeTxData: MaybeFakeTxData = { + gas: BLOCK_GAS_LIMIT, + isFakeTransaction: true, // This transaction (and only it) is allowed to come through when the lock is locked + ...callData, + from: callData.from || this._defaultFromAddress, + }; + try { + const txHash = await this._web3Wrapper.sendTransactionAsync(fakeTxData); + await this._web3Wrapper.awaitTransactionMinedAsync(txHash, 0); + } catch (err) { + // Even if this transaction failed - we've already recorded it's trace. + _.noop(); + } + await blockchainLifecycle.revertAsync(); + this._lock.release(); + } +} diff --git a/packages/sol-cov/src/trace_collector.ts b/packages/sol-cov/src/trace_collector.ts new file mode 100644 index 000000000..1b458edec --- /dev/null +++ b/packages/sol-cov/src/trace_collector.ts @@ -0,0 +1,93 @@ +import { promisify } from '@0xproject/utils'; +import { stripHexPrefix } from 'ethereumjs-util'; +import * as fs from 'fs'; +import { Collector } from 'istanbul'; +import * as _ from 'lodash'; +import { getLogger, levels, Logger } from 'loglevel'; +import * as mkdirp from 'mkdirp'; + +import { AbstractArtifactAdapter } from './artifact_adapters/abstract_artifact_adapter'; +import { constants } from './constants'; +import { parseSourceMap } from './source_maps'; +import { + ContractData, + Coverage, + SourceRange, + Subtrace, + TraceInfo, + TraceInfoExistingContract, + TraceInfoNewContract, +} from './types'; +import { utils } from './utils'; + +const mkdirpAsync = promisify<undefined>(mkdirp); + +export type SingleFileSubtraceHandler = ( + contractData: ContractData, + subtrace: Subtrace, + pcToSourceRange: { [programCounter: number]: SourceRange }, + fileIndex: number, +) => Coverage; + +/** + * TraceCollector is used by CoverageSubprovider to compute code coverage based on collected trace data. + */ +export class TraceCollector { + private _artifactAdapter: AbstractArtifactAdapter; + private _logger: Logger; + private _contractsData!: ContractData[]; + private _collector = new Collector(); + private _singleFileSubtraceHandler: SingleFileSubtraceHandler; + + /** + * Instantiates a TraceCollector instance + * @param artifactAdapter Adapter for used artifacts format (0x, truffle, giveth, etc.) + * @param isVerbose If true, we will log any unknown transactions. Otherwise we will ignore them + * @param singleFileSubtraceHandler A handler function for computing partial coverage for a single file & subtrace + */ + constructor( + artifactAdapter: AbstractArtifactAdapter, + isVerbose: boolean, + singleFileSubtraceHandler: SingleFileSubtraceHandler, + ) { + this._artifactAdapter = artifactAdapter; + this._logger = getLogger('sol-cov'); + this._logger.setLevel(isVerbose ? levels.TRACE : levels.ERROR); + this._singleFileSubtraceHandler = singleFileSubtraceHandler; + } + public async writeOutputAsync(): Promise<void> { + const finalCoverage = this._collector.getFinalCoverage(); + const stringifiedCoverage = JSON.stringify(finalCoverage, null, '\t'); + await mkdirpAsync('coverage'); + fs.writeFileSync('coverage/coverage.json', stringifiedCoverage); + } + public async computeSingleTraceCoverageAsync(traceInfo: TraceInfo): Promise<void> { + if (_.isUndefined(this._contractsData)) { + this._contractsData = await this._artifactAdapter.collectContractsDataAsync(); + } + const isContractCreation = traceInfo.address === constants.NEW_CONTRACT; + const bytecode = isContractCreation + ? (traceInfo as TraceInfoNewContract).bytecode + : (traceInfo as TraceInfoExistingContract).runtimeBytecode; + const contractData = utils.getContractDataIfExists(this._contractsData, bytecode); + if (_.isUndefined(contractData)) { + const errMsg = isContractCreation + ? `Unknown contract creation transaction` + : `Transaction to an unknown address: ${traceInfo.address}`; + this._logger.warn(errMsg); + return; + } + const bytecodeHex = stripHexPrefix(bytecode); + const sourceMap = isContractCreation ? contractData.sourceMap : contractData.sourceMapRuntime; + const pcToSourceRange = parseSourceMap(contractData.sourceCodes, sourceMap, bytecodeHex, contractData.sources); + for (let fileIndex = 0; fileIndex < contractData.sources.length; fileIndex++) { + const singleFileCoverageForTrace = this._singleFileSubtraceHandler( + contractData, + traceInfo.subtrace, + pcToSourceRange, + fileIndex, + ); + this._collector.add(singleFileCoverageForTrace); + } + } +} diff --git a/packages/sol-cov/src/trace_info_subprovider.ts b/packages/sol-cov/src/trace_info_subprovider.ts new file mode 100644 index 000000000..635a68f58 --- /dev/null +++ b/packages/sol-cov/src/trace_info_subprovider.ts @@ -0,0 +1,59 @@ +import * as _ from 'lodash'; + +import { constants } from './constants'; +import { getTracesByContractAddress } from './trace'; +import { TraceCollectionSubprovider } from './trace_collection_subprovider'; +import { TraceInfo, TraceInfoExistingContract, TraceInfoNewContract } from './types'; + +// TraceInfoSubprovider is extended by subproviders which need to work with one +// TraceInfo at a time. It has one abstract method: _handleTraceInfoAsync, which +// is called for each TraceInfo. +export abstract class TraceInfoSubprovider extends TraceCollectionSubprovider { + protected abstract _handleTraceInfoAsync(traceInfo: TraceInfo): Promise<void>; + protected async _recordTxTraceAsync(address: string, data: string | undefined, txHash: string): Promise<void> { + await this._web3Wrapper.awaitTransactionMinedAsync(txHash, 0); + const trace = await this._web3Wrapper.getTransactionTraceAsync(txHash, { + disableMemory: true, + disableStack: false, + disableStorage: true, + }); + const tracesByContractAddress = getTracesByContractAddress(trace.structLogs, address); + const subcallAddresses = _.keys(tracesByContractAddress); + if (address === constants.NEW_CONTRACT) { + for (const subcallAddress of subcallAddresses) { + let traceInfo: TraceInfoNewContract | TraceInfoExistingContract; + if (subcallAddress === 'NEW_CONTRACT') { + const traceForThatSubcall = tracesByContractAddress[subcallAddress]; + traceInfo = { + subtrace: traceForThatSubcall, + txHash, + address: subcallAddress, + bytecode: data as string, + }; + } else { + const runtimeBytecode = await this._web3Wrapper.getContractCodeAsync(subcallAddress); + const traceForThatSubcall = tracesByContractAddress[subcallAddress]; + traceInfo = { + subtrace: traceForThatSubcall, + txHash, + address: subcallAddress, + runtimeBytecode, + }; + } + await this._handleTraceInfoAsync(traceInfo); + } + } else { + for (const subcallAddress of subcallAddresses) { + const runtimeBytecode = await this._web3Wrapper.getContractCodeAsync(subcallAddress); + const traceForThatSubcall = tracesByContractAddress[subcallAddress]; + const traceInfo: TraceInfoExistingContract = { + subtrace: traceForThatSubcall, + txHash, + address: subcallAddress, + runtimeBytecode, + }; + await this._handleTraceInfoAsync(traceInfo); + } + } + } +} diff --git a/packages/sol-cov/src/types.ts b/packages/sol-cov/src/types.ts index 4c3de55a1..cef7141cb 100644 --- a/packages/sol-cov/src/types.ts +++ b/packages/sol-cov/src/types.ts @@ -1,3 +1,5 @@ +import { StructLog } from 'ethereum-types'; + export interface LineColumn { line: number; column: number; @@ -45,24 +47,24 @@ export interface StatementMap { } export interface LineCoverage { - [lineNo: number]: boolean; + [lineNo: number]: number; } export interface FunctionCoverage { - [functionId: string]: boolean; + [functionId: string]: number; } export interface StatementCoverage { - [statementId: string]: boolean; + [statementId: string]: number; } export interface BranchCoverage { - [branchId: string]: boolean[]; + [branchId: string]: number[]; } export interface Coverage { [fineName: string]: { - l: LineCoverage; + l?: LineCoverage; f: FunctionCoverage; s: StatementCoverage; b: BranchCoverage; @@ -82,8 +84,11 @@ export interface ContractData { sources: string[]; } +// Part of the trace executed within the same context +export type Subtrace = StructLog[]; + export interface TraceInfoBase { - coveredPcs: number[]; + subtrace: Subtrace; txHash: string; } @@ -102,3 +107,10 @@ export type TraceInfo = TraceInfoNewContract | TraceInfoExistingContract; export enum BlockParamLiteral { Latest = 'latest', } + +export interface EvmCallStackEntry { + structLog: StructLog; + address: string; +} + +export type EvmCallStack = EvmCallStackEntry[]; diff --git a/packages/sol-cov/src/utils.ts b/packages/sol-cov/src/utils.ts index d970c42ee..4f16a1cda 100644 --- a/packages/sol-cov/src/utils.ts +++ b/packages/sol-cov/src/utils.ts @@ -1,4 +1,9 @@ -import { LineColumn, SingleFileSourceRange } from './types'; +import { addressUtils, BigNumber } from '@0xproject/utils'; +import { OpCode, StructLog } from 'ethereum-types'; +import { addHexPrefix } from 'ethereumjs-util'; +import * as _ from 'lodash'; + +import { ContractData, LineColumn, SingleFileSourceRange } from './types'; export const utils = { compareLineColumn(lhs: LineColumn, rhs: LineColumn): number { @@ -14,4 +19,51 @@ export const utils = { utils.compareLineColumn(childRange.end, parentRange.end) <= 0 ); }, + bytecodeToBytecodeRegex(bytecode: string): string { + const bytecodeRegex = bytecode + // Library linking placeholder: __ConvertLib____________________________ + .replace(/_.*_/, '.*') + // Last 86 characters is solidity compiler metadata that's different between compilations + .replace(/.{86}$/, '') + // Libraries contain their own address at the beginning of the code and it's impossible to know it in advance + .replace(/^0x730000000000000000000000000000000000000000/, '0x73........................................'); + // HACK: Node regexes can't be longer that 32767 characters. Contracts bytecode can. We just truncate the regexes. It's safe in practice. + const MAX_REGEX_LENGTH = 32767; + const truncatedBytecodeRegex = bytecodeRegex.slice(0, MAX_REGEX_LENGTH); + return truncatedBytecodeRegex; + }, + getContractDataIfExists(contractsData: ContractData[], bytecode: string): ContractData | undefined { + if (!bytecode.startsWith('0x')) { + throw new Error(`0x hex prefix missing: ${bytecode}`); + } + const contractData = _.find(contractsData, contractDataCandidate => { + const bytecodeRegex = utils.bytecodeToBytecodeRegex(contractDataCandidate.bytecode); + const runtimeBytecodeRegex = utils.bytecodeToBytecodeRegex(contractDataCandidate.runtimeBytecode); + // We use that function to find by bytecode or runtimeBytecode. Those are quasi-random strings so + // collisions are practically impossible and it allows us to reuse that code + return !_.isNull(bytecode.match(bytecodeRegex)) || !_.isNull(bytecode.match(runtimeBytecodeRegex)); + }); + return contractData; + }, + isCallLike(op: OpCode): boolean { + return _.includes([OpCode.CallCode, OpCode.StaticCall, OpCode.Call, OpCode.DelegateCall], op); + }, + isEndOpcode(op: OpCode): boolean { + return _.includes([OpCode.Return, OpCode.Stop, OpCode.Revert, OpCode.Invalid, OpCode.SelfDestruct], op); + }, + getAddressFromStackEntry(stackEntry: string): string { + const hexBase = 16; + return addressUtils.padZeros(new BigNumber(addHexPrefix(stackEntry)).toString(hexBase)); + }, + normalizeStructLogs(structLogs: StructLog[]): StructLog[] { + if (structLogs[0].depth === 1) { + // Geth uses 1-indexed depth counter whilst ganache starts from 0 + const newStructLogs = _.map(structLogs, structLog => ({ + ...structLog, + depth: structLog.depth - 1, + })); + return newStructLogs; + } + return structLogs; + }, }; diff --git a/packages/sol-cov/test/instructions_test.ts b/packages/sol-cov/test/instructions_test.ts index 8012674f5..02f30a5ca 100644 --- a/packages/sol-cov/test/instructions_test.ts +++ b/packages/sol-cov/test/instructions_test.ts @@ -1,8 +1,6 @@ import * as chai from 'chai'; -import * as fs from 'fs'; import 'make-promises-safe'; import 'mocha'; -import * as path from 'path'; import { constants } from '../src/constants'; import { getPcToInstructionIndexMapping } from '../src/instructions'; diff --git a/packages/sol-cov/test/trace_test.ts b/packages/sol-cov/test/trace_test.ts index 07e270b71..7a034362c 100644 --- a/packages/sol-cov/test/trace_test.ts +++ b/packages/sol-cov/test/trace_test.ts @@ -1,9 +1,7 @@ import * as chai from 'chai'; import { OpCode, StructLog } from 'ethereum-types'; -import * as fs from 'fs'; import * as _ from 'lodash'; import 'mocha'; -import * as path from 'path'; import { getTracesByContractAddress } from '../src/trace'; |