├── .npmrc ├── ngi-eu-footer.png ├── typedoc.json ├── tsconfig.json ├── test ├── start-test-admin-server.ts ├── test-setup.ts ├── integration │ ├── chain-state.spec.ts │ ├── proxy.spec.ts │ ├── smoke-test.spec.ts │ ├── balance.spec.ts │ ├── send.spec.ts │ └── call.spec.ts └── abi.spec.ts ├── .github └── workflows │ └── ci.yml ├── wallaby.js ├── src ├── abi.ts ├── main.ts ├── admin-bin.ts ├── jsonrpc.ts ├── mocked-contract.ts ├── single-value-rule-builders.ts ├── mock-node.ts └── contract-rule-builder.ts ├── karma.conf.ts ├── .gitignore ├── package.json ├── LICENSE └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /ngi-eu-footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/httptoolkit/mockthereum/HEAD/ngi-eu-footer.png -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mockthereum API Reference", 3 | "readme": "none", 4 | "validation": { 5 | "invalidLink": true 6 | }, 7 | "treatWarningsAsErrors": true, 8 | "excludePrivate": true, 9 | "excludeProtected": true, 10 | "excludeInternal": true, 11 | "out": "typedoc/" 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": [ 12 | "custom-typings/*.d.ts", 13 | "src/**/*.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /test/start-test-admin-server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | const Mockthereum = require('../src/main'); 7 | Mockthereum.getAdminServer().start().then(() => { 8 | console.log("Test admin server started"); 9 | }).catch((error: any) => { 10 | console.error(error); 11 | process.exit(1); 12 | }); -------------------------------------------------------------------------------- /test/test-setup.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { expect } from "chai"; 7 | 8 | import * as Mockthereum from '../src/main'; 9 | export { 10 | expect, 11 | Mockthereum 12 | }; 13 | 14 | export const delay = (durationMs: number) => 15 | new Promise((resolve) => setTimeout(resolve, durationMs)); 16 | 17 | const isNode = typeof process === 'object' && process.version; 18 | export const nodeOnly = (fn: Function) => { 19 | if (isNode) fn(); 20 | }; -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build & test 6 | runs-on: ubuntu-latest 7 | 8 | strategy: 9 | matrix: 10 | node-version: [16.x, 18.x, v18.16] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | 19 | - run: npm install --no-package-lock 20 | - run: npm test 21 | 22 | - name: Deploy docs 23 | if: github.ref == 'refs/heads/main' && matrix.node-version == 'v18.16' 24 | uses: JamesIves/github-pages-deploy-action@v4.2.2 25 | with: 26 | single-commit: true 27 | branch: gh-pages 28 | folder: typedoc -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = (wallaby) => { 2 | return { 3 | files: [ 4 | 'package.json', 5 | 'src/**/*.ts', 6 | 'test/**/*.ts', 7 | '!test/**/*.spec.ts' 8 | ], 9 | tests: [ 10 | 'test/**/*.spec.ts' 11 | ], 12 | 13 | preprocessors: { 14 | // Package.json points `main` to the built output. We use this a lot in the integration tests, but we 15 | // want wallaby to run on raw source. This is a simple remap of paths to lets us do that. 16 | 'test/integration/**/*.ts': file => { 17 | return file.content.replace( 18 | /("|')..((\/..)+)("|')/g, 19 | '"..$2/src/main"' 20 | ); 21 | } 22 | }, 23 | 24 | workers: { 25 | initial: 1, 26 | regular: 1, 27 | restart: true 28 | }, 29 | 30 | testFramework: 'mocha', 31 | env: { 32 | type: 'node' 33 | }, 34 | debug: true 35 | }; 36 | }; -------------------------------------------------------------------------------- /src/abi.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { id as idHash } from '@ethersproject/hash'; 7 | import { defaultAbiCoder, FunctionFragment } from '@ethersproject/abi'; 8 | 9 | export const encodeAbi = defaultAbiCoder.encode.bind(defaultAbiCoder); 10 | export const decodeAbi = defaultAbiCoder.decode.bind(defaultAbiCoder); 11 | 12 | export function parseFunctionSignature(functionDefinition: string) { 13 | return FunctionFragment.from( 14 | functionDefinition.trim().replace(/^function /, '') // Be flexible with input format 15 | ) 16 | } 17 | 18 | export function encodeFunctionSignature(functionDefinition: string | FunctionFragment) { 19 | if (!(functionDefinition instanceof FunctionFragment)) { 20 | functionDefinition = parseFunctionSignature(functionDefinition); 21 | } 22 | 23 | return idHash(functionDefinition.format()).slice(0, 10); 24 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from 'mockttp'; 7 | import { MockthereumNode, MockthereumOptions } from './mock-node'; 8 | 9 | export function getLocal(options?: Mockttp.MockttpOptions & MockthereumOptions) { 10 | return new MockthereumNode(Mockttp.getLocal(options), options); 11 | } 12 | 13 | export function getRemote(options?: Mockttp.MockttpOptions & MockthereumOptions) { 14 | return new MockthereumNode(Mockttp.getRemote(options), options); 15 | } 16 | 17 | export function getAdminServer(options?: Mockttp.MockttpAdminServerOptions) { 18 | return Mockttp.getAdminServer(options); 19 | } 20 | 21 | // Export various internal types: 22 | export type { MockthereumNode, MockthereumOptions }; 23 | export type { CallRuleBuilder, TransactionRuleBuilder } from './contract-rule-builder'; 24 | export type { BalanceRuleBuilder, BlockNumberRuleBuilder, GasPriceRuleBuilder } from './single-value-rule-builders'; 25 | export type { MockedContract } from './mocked-contract'; 26 | export type { RpcErrorProperties } from './jsonrpc'; 27 | export type { RawTransactionReceipt } from './mock-node'; -------------------------------------------------------------------------------- /test/integration/chain-state.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import Web3 from "web3"; 7 | 8 | import { Mockthereum, expect } from "../test-setup"; 9 | 10 | describe("Chain state queries", () => { 11 | 12 | const mockNode = Mockthereum.getLocal(); 13 | 14 | beforeEach(() => mockNode.start()); 15 | afterEach(() => mockNode.stop()); 16 | 17 | it("should return block number 1 by default", async () => { 18 | const web3 = new Web3(mockNode.url); 19 | const result = await web3.eth.getBlockNumber(); 20 | 21 | expect(result).to.equal(1); 22 | }); 23 | 24 | it("should return gas price 1000 wei by default", async () => { 25 | const web3 = new Web3(mockNode.url); 26 | const result = await web3.eth.getGasPrice(); 27 | 28 | expect(result).to.equal('1000'); 29 | }); 30 | 31 | it("can be mocked to return a specific block number", async () => { 32 | await mockNode.forBlockNumber().thenReturn(1000); 33 | 34 | const web3 = new Web3(mockNode.url); 35 | const walletBalance = await web3.eth.getBlockNumber(); 36 | 37 | expect(walletBalance).to.equal(1000); 38 | }); 39 | 40 | it("can be mocked to return a specific gas price", async () => { 41 | await mockNode.forGasPrice().thenReturn(1234); 42 | 43 | const web3 = new Web3(mockNode.url); 44 | const walletBalance = await web3.eth.getGasPrice(); 45 | 46 | expect(walletBalance).to.equal('1234'); 47 | }); 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /src/admin-bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * SPDX-FileCopyrightText: 2022 Tim Perry 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import childProcess = require('child_process'); 8 | import Mockthereum = require('./main'); 9 | 10 | handleArgs(process.argv).catch((e) => { 11 | console.error(e); 12 | process.exit(1); 13 | }); 14 | 15 | async function handleArgs(args: string[]) { 16 | const remainingArgs = args.slice(2); 17 | let nextArg = remainingArgs.shift(); 18 | while (nextArg) { 19 | if (nextArg === '-c') { 20 | await runCommandWithServer(remainingArgs.join(' ')); 21 | return; 22 | } else { 23 | break; 24 | } 25 | } 26 | 27 | console.log("Usage: mockthereum -c "); 28 | process.exit(1); 29 | } 30 | 31 | async function runCommandWithServer(command: string) { 32 | const server = Mockthereum.getAdminServer(); 33 | await server.start(); 34 | 35 | let realProcess = childProcess.spawn(command, [], { 36 | shell: true, 37 | stdio: 'inherit' 38 | }); 39 | 40 | realProcess.on('error', (error) => { 41 | server.stop().then(function () { 42 | console.error(error); 43 | process.exit(1); 44 | }); 45 | }); 46 | 47 | realProcess.on('exit', (code, signal) => { 48 | server.stop().then(function () { 49 | if (code == null) { 50 | console.error('Executed process exited due to signal: ' + signal); 51 | process.exit(1); 52 | } else { 53 | process.exit(code); 54 | } 55 | }); 56 | }); 57 | } -------------------------------------------------------------------------------- /karma.conf.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill'; 7 | import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; 8 | 9 | const CONTINUOUS = process.env.CONTINUOUS_TEST === 'true'; 10 | const HEADFUL = process.env.HEADFUL_TEST === 'true'; 11 | 12 | require('./test/start-test-admin-server'); 13 | 14 | module.exports = function(config: any) { 15 | config.set({ 16 | frameworks: ['mocha', 'chai'], 17 | files: [ 18 | 'test/**/*.spec.ts' 19 | ], 20 | preprocessors: { 21 | 'src/**/*.ts': ['esbuild'], 22 | 'test/**/*.ts': ['esbuild'] 23 | }, 24 | esbuild: { 25 | format: 'esm', 26 | target: 'esnext', 27 | external: ['http-encoding'], 28 | plugins: [ 29 | NodeModulesPolyfillPlugin(), 30 | NodeGlobalsPolyfillPlugin({ 31 | process: true, 32 | buffer: true 33 | }) 34 | ] 35 | }, 36 | plugins: [ 37 | 'karma-chrome-launcher', 38 | 'karma-chai', 39 | 'karma-mocha', 40 | 'karma-spec-reporter', 41 | 'karma-esbuild' 42 | ], 43 | reporters: ['spec'], 44 | port: 9876, 45 | logLevel: config.LOG_INFO, 46 | 47 | browsers: HEADFUL 48 | ? ['Chrome'] 49 | : ['ChromeHeadless'], 50 | 51 | autoWatch: CONTINUOUS, 52 | singleRun: !CONTINUOUS 53 | }); 54 | }; -------------------------------------------------------------------------------- /src/jsonrpc.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from 'mockttp'; 7 | 8 | export class RpcCallMatcher extends Mockttp.matchers.JsonBodyFlexibleMatcher { 9 | 10 | constructor( 11 | method: string, 12 | params: Array = [] 13 | ) { 14 | super({ 15 | jsonrpc: "2.0", 16 | method, 17 | params 18 | }); 19 | } 20 | 21 | } 22 | 23 | export class RpcResponseHandler extends Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition { 24 | 25 | constructor(result: unknown) { 26 | super(async (req) => ({ 27 | headers: { 'transfer-encoding': 'chunked', 'connection': 'keep-alive' }, 28 | json: { 29 | jsonrpc: "2.0", 30 | id: (await req.body.getJson() as { id: number }).id, 31 | result 32 | } 33 | })); 34 | } 35 | 36 | } 37 | 38 | export interface RpcErrorProperties { 39 | code?: number; 40 | name?: string; 41 | data?: `0x${string}`; 42 | } 43 | 44 | export class RpcErrorResponseHandler extends Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition { 45 | 46 | constructor(message: string, options: RpcErrorProperties = {}) { 47 | super(async (req) => ({ 48 | headers: { 'transfer-encoding': 'chunked', 'connection': 'keep-alive' }, 49 | json: { 50 | jsonrpc: "2.0", 51 | id: (await req.body.getJson() as { id: number }).id, 52 | error: { 53 | message, 54 | data: options.data ?? '0x', 55 | code: options.code ?? -32099, 56 | name: options.name ?? undefined, 57 | } 58 | } 59 | })); 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /test/abi.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { expect } from 'chai'; 7 | import { 8 | encodeAbi, 9 | decodeAbi, 10 | encodeFunctionSignature 11 | } from '../src/abi' 12 | 13 | describe("ABI encoding", () => { 14 | it("can encode & decode strings", () => { 15 | const encoded = encodeAbi(['string'], ['hello']); 16 | 17 | expect(encoded).to.match(/^0x/); 18 | 19 | expect(decodeAbi(['string'], encoded)).to.deep.equal(['hello']); 20 | }); 21 | 22 | it("can encode & decode numbers", () => { 23 | const encoded = encodeAbi(['uint8', 'int256'], [123, 456]); 24 | 25 | expect(encoded).to.match(/^0x/); 26 | 27 | expect(decodeAbi(['uint8', 'int256'], encoded).map(n => 28 | n.toNumber ? n.toNumber(): n // Flatten BigNumber into numbers 29 | )).to.deep.equal([123, 456]); 30 | }); 31 | 32 | it("can encode & decode bytes", () => { 33 | const encoded = encodeAbi(['bytes'], [[1, 2, 3, 4]]); 34 | 35 | expect(encoded).to.match(/^0x/); 36 | 37 | expect(decodeAbi(['bytes'], encoded)).to.deep.equal(['0x01020304']); 38 | }); 39 | 40 | it("can generate function signature hashes", () => { 41 | expect( 42 | encodeFunctionSignature("foobar(string,bool)") 43 | ).to.equal("0x7fddde58"); 44 | }); 45 | 46 | it("can generate function signature hashes from various input formats", () => { 47 | expect( 48 | encodeFunctionSignature("foobar(string, bool)") // Space 49 | ).to.equal("0x7fddde58"); 50 | 51 | expect( 52 | encodeFunctionSignature("function foobar(string, bool)") // Function prefix 53 | ).to.equal("0x7fddde58"); 54 | 55 | expect( 56 | encodeFunctionSignature("function foobar(string,bool) public view returns (bool)") // Return types & flags 57 | ).to.equal("0x7fddde58"); 58 | }); 59 | }); -------------------------------------------------------------------------------- /test/integration/proxy.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import Web3 from "web3"; 7 | import * as ganache from 'ganache'; 8 | 9 | import { expect, nodeOnly, Mockthereum } from '../test-setup'; 10 | 11 | nodeOnly(() => { 12 | describe("Proxying traffic to a real Ethereum node", () => { 13 | 14 | const NODE_PORT = 8555; 15 | let realNode: ganache.Server; 16 | 17 | before(async () => { 18 | realNode = ganache.server(); 19 | return new Promise((resolve, reject) => { 20 | realNode.listen(NODE_PORT); 21 | realNode.on('open', (err) => { 22 | if (err) reject(err); 23 | else resolve(); 24 | }); 25 | }); 26 | }); 27 | 28 | after(() => realNode.close()); 29 | 30 | const mockNode = Mockthereum.getLocal({ 31 | unmatchedRequests: { proxyTo: 'http://localhost:8555' } 32 | }); 33 | 34 | beforeEach(() => mockNode.start()); 35 | afterEach(() => mockNode.stop()); 36 | 37 | it("should forward unmatched requests", async () => { 38 | const web3 = new Web3(mockNode.url); 39 | 40 | const nodeInfo = await web3.eth.getNodeInfo(); 41 | 42 | expect(nodeInfo).to.include("Ganache"); 43 | }); 44 | 45 | it("should allow observing forwarded requests", async () => { 46 | const web3 = new Web3(mockNode.url); 47 | 48 | await web3.eth.getBalance('0x1230000000000000000000000000000000000000'); 49 | 50 | const seenBalanceCalls = await mockNode.getSeenMethodCalls('eth_getBalance'); 51 | expect(seenBalanceCalls).to.have.lengthOf(1); 52 | expect(seenBalanceCalls[0].params).to.deep.equal([ 53 | '0x1230000000000000000000000000000000000000', 54 | 'latest' 55 | ]); 56 | }); 57 | 58 | }); 59 | }); -------------------------------------------------------------------------------- /src/mocked-contract.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from 'mockttp'; 7 | import { decodeAbi } from './abi'; 8 | 9 | /** 10 | * A mocked contract. This is returned by a rule builder when the rule is created, and can 11 | * be used to query the requests that have been seen by the mock rule. 12 | */ 13 | export class MockedContract { 14 | 15 | constructor( 16 | private endpoint: Mockttp.MockedEndpoint, 17 | private paramTypes?: string[] | undefined 18 | ) {} 19 | 20 | /** 21 | * Returns a promise that resolves to an array of requests seen by the mock rule. 22 | * 23 | * For each request, this includes: 24 | * - `to`: the contract address 25 | * - `from`: the sender address, if used, or undefined for contract calls 26 | * - `value`: the value sent, if any, or undefined for contract calls 27 | * - `params`: the decoded params, if a function signature or param types were provided 28 | * using `forFunction` or `withParams` methods when creating the rule. 29 | * - `rawRequest` - the raw HTTP request sent to the node 30 | */ 31 | async getRequests() { 32 | const requests = await this.endpoint.getSeenRequests(); 33 | 34 | return Promise.all(requests.map(async (req) => { 35 | const jsonBody: any = (await req.body.getJson()); 36 | const { 37 | data, 38 | value, 39 | to, 40 | from 41 | } = jsonBody?.params[0] ?? {}; 42 | const encodedCallParams = data ? `0x${data.slice(10)}` : undefined; 43 | 44 | return { 45 | rawRequest: req, 46 | to, 47 | from, 48 | value, 49 | params: (this.paramTypes && encodedCallParams) 50 | ? decodeAbi(this.paramTypes, encodedCallParams) 51 | : undefined 52 | }; 53 | })); 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | typedoc -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mockthereum", 3 | "version": "0.2.0", 4 | "description": "Powerful friendly Ethereum mock node & proxy ", 5 | "main": "dist/main.js", 6 | "types": "dist/main.d.ts", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "bin": { 11 | "mockthereum": "./dist/admin-bin.js" 12 | }, 13 | "scripts": { 14 | "build": "npm run build:src && npm run build:doc", 15 | "build:src": "tsc && chmod +x ./dist/admin-bin.js", 16 | "build:doc": "typedoc src/main.ts", 17 | "pretest": "npm run build", 18 | "test": "npm run test:node && npm run test:browser", 19 | "test:node": "mocha -r ts-node/register 'test/**/*.spec.ts'", 20 | "test:browser": "karma start", 21 | "test:browser:dev": "CONTINUOUS_TEST=true npm run test", 22 | "test:browser:debug": "HEADFUL_TEST=true CONTINUOUS_TEST=true npm run test", 23 | "prepack": "npm run build" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/httptoolkit/mockthereum.git" 28 | }, 29 | "keywords": [ 30 | "ethereum", 31 | "eth", 32 | "web3", 33 | "dapp", 34 | "mock", 35 | "test", 36 | "proxy" 37 | ], 38 | "author": "Tim Perry ", 39 | "license": "Apache-2.0", 40 | "bugs": { 41 | "url": "https://github.com/httptoolkit/mockthereum/issues" 42 | }, 43 | "homepage": "https://github.com/httptoolkit/mockthereum#readme", 44 | "engines": { 45 | "node": ">=16.0.0" 46 | }, 47 | "peerDependencies": { 48 | "mockttp": "^3.1.0" 49 | }, 50 | "devDependencies": { 51 | "@esbuild-plugins/node-globals-polyfill": "^0.1.1", 52 | "@esbuild-plugins/node-modules-polyfill": "^0.1.4", 53 | "@types/chai": "^4.2.22", 54 | "@types/lodash": "^4.14.177", 55 | "@types/mocha": "^9.0.0", 56 | "@types/uuid": "^8.3.4", 57 | "chai": "^4.3.4", 58 | "esbuild": "^0.14.38", 59 | "ganache": "^7.4.1", 60 | "karma": "^6.3.19", 61 | "karma-chai": "^0.1.0", 62 | "karma-chrome-launcher": "^3.1.1", 63 | "karma-esbuild": "^2.2.4", 64 | "karma-mocha": "^2.0.1", 65 | "karma-spec-reporter": "^0.0.34", 66 | "mocha": "^9.1.3", 67 | "ts-node": "^10.4.0", 68 | "typedoc": "^0.23.11", 69 | "typescript": "^4.5.2", 70 | "web3": "^1.7.3", 71 | "web3-utils": "^1.7.3" 72 | }, 73 | "dependencies": { 74 | "@ethersproject/abi": "^5.6.3", 75 | "mockttp": "^3.1.0", 76 | "uuid": "^8.3.2" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/integration/smoke-test.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import type { AbiItem } from 'web3-utils' 7 | import Web3 from "web3"; 8 | 9 | import { Mockthereum, expect } from "../test-setup"; 10 | 11 | // Parameters for some real Web3 contract: 12 | const CONTRACT_ADDRESS = "0x283af0b28c62c092c9727f1ee09c02ca627eb7f5"; 13 | const JSON_CONTRACT_ABI = [ 14 | { 15 | type: "function", 16 | name: "getText", 17 | stateMutability: "view", 18 | inputs: [ 19 | { name: "key", type: "string" } 20 | ], 21 | outputs: [{ internalType: "string", name: "", type: "string" }] 22 | } 23 | ] as AbiItem[]; 24 | 25 | describe("Smoke test", () => { 26 | 27 | // Create a mock Ethereum node: 28 | const mockNode = Mockthereum.getLocal(); 29 | 30 | // Start & stop your mock node to reset state between tests 31 | beforeEach(() => mockNode.start()); 32 | afterEach(() => mockNode.stop()); 33 | 34 | it("lets you mock behaviour and assert on Ethereum interactions", async () => { 35 | // Mock any address balance 36 | await mockNode.forBalance('0x0000000000000000000000000000000000000001') 37 | .thenReturn(1000); 38 | 39 | // Mock any contract's function call: 40 | const mockedFunction = await mockNode.forCall(CONTRACT_ADDRESS) // Match any contract address 41 | // Optionally, match specific functions and parameters: 42 | .forFunction('function getText(string key) returns (string)') 43 | .withParams(["test"]) 44 | // Mock contract results: 45 | .thenReturn('Mock result'); 46 | 47 | // Real code to make client requests to the Ethereum node: 48 | const web3 = new Web3(mockNode.url); 49 | 50 | const walletBalance = await web3.eth.getBalance('0x0000000000000000000000000000000000000001'); 51 | expect(walletBalance).to.equal("1000"); // Returns our mocked wallet balance 52 | 53 | const contract = new web3.eth.Contract(JSON_CONTRACT_ABI, CONTRACT_ADDRESS); 54 | const contractResult = await contract.methods.getText("test").call(); 55 | 56 | // Check contract call returns our fake contract result: 57 | expect(contractResult).to.equal("Mock result"); 58 | 59 | // Assert on inputs, to check we saw the contract calls we expected: 60 | const mockedCalls = await mockedFunction.getRequests(); 61 | expect(mockedCalls.length).to.equal(1); 62 | 63 | expect(mockedCalls[0]).to.deep.include({ 64 | // Examine full interaction data, included decoded parameters etc: 65 | to: CONTRACT_ADDRESS, 66 | params: ["test"] 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/integration/balance.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import Web3 from "web3"; 7 | 8 | import { Mockthereum, expect, delay } from "../test-setup"; 9 | 10 | describe("Wallet balance", () => { 11 | 12 | const mockNode = Mockthereum.getLocal(); 13 | 14 | // Start & stop your mock node to reset state between tests 15 | beforeEach(() => mockNode.start()); 16 | afterEach(() => mockNode.stop()); 17 | 18 | it("should return 0 by default", async () => { 19 | const web3 = new Web3(mockNode.url); 20 | const walletBalance = await web3.eth.getBalance('0x0000000000000000000000000000000000000000'); 21 | 22 | expect(walletBalance).to.equal("0"); 23 | }); 24 | 25 | it("can be mocked to return a specific value for all wallets", async () => { 26 | await mockNode.forBalance().thenReturn(1000); 27 | 28 | const web3 = new Web3(mockNode.url); 29 | const walletBalance = await web3.eth.getBalance('0x0000000000000000000000000000000000000000'); 30 | 31 | expect(walletBalance).to.equal("1000"); 32 | }); 33 | 34 | it("can be mocked to return a specific value per wallet", async () => { 35 | await mockNode.forBalance('0x0000000000000000000000000000000000000001').thenReturn(1); 36 | await mockNode.forBalance('0x0000000000000000000000000000000000000002').thenReturn(2); 37 | await mockNode.forBalance('0x0000000000000000000000000000000000000003').thenReturn(3); 38 | 39 | const web3 = new Web3(mockNode.url); 40 | const [w3, w2, w1] = await Promise.all([ 41 | // Reverse order, just to ensure 100% we are mapping results correctly: 42 | web3.eth.getBalance('0x0000000000000000000000000000000000000003'), 43 | web3.eth.getBalance('0x0000000000000000000000000000000000000002'), 44 | web3.eth.getBalance('0x0000000000000000000000000000000000000001') 45 | ]); 46 | 47 | expect(w1).to.equal('1'); 48 | expect(w2).to.equal('2'); 49 | expect(w3).to.equal('3'); 50 | }); 51 | 52 | it("can be mocked to return an RPC error", async () => { 53 | await mockNode.forBalance().thenError("Mock error"); 54 | 55 | const web3 = new Web3(mockNode.url); 56 | const result = await web3.eth.getBalance('0x0000000000000000000000000000000000000000').catch(e => e); 57 | 58 | expect(result).to.be.instanceOf(Error); 59 | expect(result.message).to.equal( 60 | "Returned error: Mock error" 61 | ); 62 | }); 63 | 64 | it("can be mocked to close the connection", async () => { 65 | await mockNode.forBalance().thenCloseConnection(); 66 | 67 | const web3 = new Web3(mockNode.url); 68 | const result = await web3.eth.getBalance('0x0000000000000000000000000000000000000000').catch(e => e); 69 | 70 | expect(result).to.be.instanceOf(Error); 71 | expect(result.message).to.include('CONNECTION ERROR'); 72 | }); 73 | 74 | it("can be mocked to timeout", async () => { 75 | await mockNode.forBalance().thenTimeout(); 76 | 77 | const web3 = new Web3(mockNode.url); 78 | const result = await Promise.race([ 79 | web3.eth.getBalance('0x0000000000000000000000000000000000000000').catch(e => e), 80 | delay(500).then(() => 'timeout') 81 | ]); 82 | 83 | expect(result).to.equal('timeout'); 84 | }); 85 | 86 | }); 87 | -------------------------------------------------------------------------------- /src/single-value-rule-builders.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from 'mockttp'; 7 | import { RpcCallMatcher, RpcErrorResponseHandler, RpcResponseHandler } from './jsonrpc'; 8 | 9 | class SingleValueRuleBuilder { 10 | 11 | constructor( 12 | private addRuleCallback: (rule: Mockttp.RequestRuleData) => Promise, 13 | private matchers: Mockttp.matchers.RequestMatcher[] = [] 14 | ) {} 15 | 16 | /** 17 | * Successfully return a given value. 18 | */ 19 | thenReturn(value: number) { 20 | return this.addRuleCallback({ 21 | matchers: this.matchers, 22 | handler: new RpcResponseHandler(`0x${value.toString(16)}`) 23 | }); 24 | } 25 | 26 | /** 27 | * Fail and return an error message. 28 | */ 29 | thenError(message: string) { 30 | return this.addRuleCallback({ 31 | matchers: this.matchers, 32 | handler: new RpcErrorResponseHandler(message) 33 | }); 34 | } 35 | 36 | /** 37 | * Timeout, accepting the request but never returning a response. 38 | * 39 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 40 | */ 41 | thenTimeout() { 42 | return this.addRuleCallback({ 43 | matchers: this.matchers, 44 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 45 | }); 46 | } 47 | 48 | /** 49 | * Close the connection immediately after receiving the matching request, without sending any response. 50 | * 51 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 52 | */ 53 | thenCloseConnection() { 54 | return this.addRuleCallback({ 55 | matchers: this.matchers, 56 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 57 | }); 58 | } 59 | 60 | } 61 | 62 | /** 63 | * A rule builder to allow defining rules that mock an Ethereum wallet balance. 64 | */ 65 | export class BalanceRuleBuilder extends SingleValueRuleBuilder { 66 | 67 | /** 68 | * This builder should not be constructed directly. Call `mockNode.forBalance()` instead. 69 | */ 70 | constructor( 71 | address: string | undefined, 72 | addRuleCallback: (rule: Mockttp.RequestRuleData) => Promise 73 | ) { 74 | if (address) { 75 | super(addRuleCallback, [new RpcCallMatcher('eth_getBalance', [address])]); 76 | } else { 77 | super(addRuleCallback, [new RpcCallMatcher('eth_getBalance')]); 78 | } 79 | } 80 | 81 | } 82 | 83 | /** 84 | * A rule builder to allow defining rules that mock the current block number. 85 | */ 86 | export class BlockNumberRuleBuilder extends SingleValueRuleBuilder { 87 | 88 | /** 89 | * This builder should not be constructed directly. Call `mockNode.forBlockNumber()` instead. 90 | */ 91 | constructor( 92 | addRuleCallback: (rule: Mockttp.RequestRuleData) => Promise 93 | ) { 94 | super(addRuleCallback, [new RpcCallMatcher('eth_blockNumber')]); 95 | } 96 | 97 | } 98 | 99 | /** 100 | * A rule builder to allow defining rules that mock the current gas price. 101 | */ 102 | export class GasPriceRuleBuilder extends SingleValueRuleBuilder { 103 | 104 | /** 105 | * This builder should not be constructed directly. Call `mockNode.forGasPrice()` instead. 106 | */ 107 | constructor( 108 | addRuleCallback: (rule: Mockttp.RequestRuleData) => Promise 109 | ) { 110 | super(addRuleCallback, [new RpcCallMatcher('eth_gasPrice')]); 111 | } 112 | 113 | } -------------------------------------------------------------------------------- /test/integration/send.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import Web3 from "web3"; 7 | 8 | import { Mockthereum, expect, delay } from "../test-setup"; 9 | 10 | const CONTRACT_ADDRESS = '0x0000000000000000000000000000000000000000'; 11 | const FROM_ADDRESS = '0x1111111111111111111111111111111111111111'; 12 | const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999'; 13 | 14 | describe("Contract send calls", () => { 15 | 16 | const mockNode = Mockthereum.getLocal(); 17 | 18 | // Start & stop your mock node to reset state between tests 19 | beforeEach(() => mockNode.start()); 20 | afterEach(() => mockNode.stop()); 21 | 22 | describe("with eth_sendTransaction", () => { 23 | 24 | it("should return an error for unmatched contract sends by default", async () => { 25 | const web3 = new Web3(mockNode.url); 26 | const result = await web3.eth.sendTransaction({ 27 | from: FROM_ADDRESS, 28 | to: CONTRACT_ADDRESS, 29 | value: 1 30 | }).catch(e => e); 31 | 32 | expect(result).to.be.instanceOf(Error); 33 | expect(result.message).to.equal( 34 | "Returned error: No Mockthereum rules found matching Ethereum transaction" 35 | ); 36 | }); 37 | 38 | it("can be matched by 'to' address for direct send()", async () => { 39 | await mockNode.forSendTransactionTo(CONTRACT_ADDRESS) 40 | .thenSucceed(); 41 | 42 | const web3 = new Web3(mockNode.url); 43 | const matchingResult = await web3.eth.sendTransaction({ 44 | from: FROM_ADDRESS, 45 | to: CONTRACT_ADDRESS, 46 | value: 1 47 | }).catch(e => e); 48 | const nonMatchingResult = await web3.eth.sendTransaction({ 49 | from: FROM_ADDRESS, 50 | to: OTHER_ADDRESS, 51 | value: 1 52 | }).catch(e => e); 53 | 54 | expect(matchingResult).to.deep.include({ 55 | status: true, 56 | blockNumber: 256, 57 | blockHash: '0x1', 58 | from: FROM_ADDRESS, 59 | to: CONTRACT_ADDRESS, 60 | cumulativeGasUsed: 1, 61 | gasUsed: 1, 62 | effectiveGasPrice: 0, 63 | contractAddress: null, 64 | logs: [], 65 | logsBloom: '0x0', 66 | type: '0x0', 67 | transactionIndex: undefined 68 | }); 69 | expect(nonMatchingResult).to.be.instanceOf(Error); 70 | }); 71 | 72 | it("can reject contract sends with a custom error on submission", async () => { 73 | await mockNode.forSendTransactionTo(CONTRACT_ADDRESS) 74 | .thenFailImmediately("Mock error"); 75 | 76 | const web3 = new Web3(mockNode.url); 77 | const errorResult = await web3.eth.sendTransaction({ 78 | to: CONTRACT_ADDRESS, 79 | from: FROM_ADDRESS 80 | }).catch(e => e); 81 | 82 | expect(errorResult).to.be.instanceOf(Error); 83 | expect(errorResult.message).to.equal( 84 | "Returned error: Mock error" 85 | ); 86 | }); 87 | 88 | it("can reject contract sends with a custom revert error in processing", async () => { 89 | await mockNode.forSendTransactionTo(CONTRACT_ADDRESS) 90 | .thenRevert(); 91 | 92 | const web3 = new Web3(mockNode.url); 93 | const errorResult = await web3.eth.sendTransaction({ 94 | to: CONTRACT_ADDRESS, 95 | from: FROM_ADDRESS 96 | }).catch(e => e); 97 | 98 | expect(errorResult).to.be.instanceOf(Error); 99 | expect(errorResult.message).to.match( 100 | /^Transaction has been reverted by the EVM:/ 101 | ); 102 | }); 103 | 104 | it("can be mocked to close the connection", async () => { 105 | await mockNode.forSendTransaction().thenCloseConnection(); 106 | 107 | const web3 = new Web3(mockNode.url); 108 | const result = await web3.eth.sendTransaction({ 109 | to: CONTRACT_ADDRESS, 110 | from: FROM_ADDRESS 111 | }).catch(e => e); 112 | 113 | expect(result).to.be.instanceOf(Error); 114 | expect(result.message).to.include('CONNECTION ERROR'); 115 | }); 116 | 117 | it("can be mocked to timeout", async () => { 118 | await mockNode.forSendTransaction().thenTimeout(); 119 | 120 | const web3 = new Web3(mockNode.url); 121 | const result = await Promise.race([ 122 | web3.eth.sendTransaction({ 123 | to: CONTRACT_ADDRESS, 124 | from: FROM_ADDRESS 125 | }).catch(e => e), 126 | delay(500).then(() => 'timeout') 127 | ]); 128 | 129 | expect(result).to.equal('timeout'); 130 | }); 131 | }); 132 | }); -------------------------------------------------------------------------------- /test/integration/call.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import Web3 from "web3"; 7 | 8 | import { decodeAbi, encodeAbi } from "../../src/abi"; 9 | 10 | import { Mockthereum, expect, delay } from "../test-setup"; 11 | 12 | const CONTRACT_ADDRESS = '0x0000000000000000000000000000000000000000'; 13 | const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999'; 14 | 15 | describe("Contract eth_call()", () => { 16 | 17 | const mockNode = Mockthereum.getLocal(); 18 | 19 | // Start & stop your mock node to reset state between tests 20 | beforeEach(() => mockNode.start()); 21 | afterEach(() => mockNode.stop()); 22 | 23 | it("should return an error for unmatched contract calls by default", async () => { 24 | const web3 = new Web3(mockNode.url); 25 | const result = await web3.eth.call({ 26 | to: CONTRACT_ADDRESS, 27 | data: '0x73eac5b1' + encodeAbi(['bool', 'string'], [true, 'test']).slice(2) 28 | }).catch(e => e); 29 | 30 | expect(result).to.be.instanceOf(Error); 31 | expect(result.message).to.equal( 32 | "Returned error: No Mockthereum rules found matching Ethereum contract call" 33 | ); 34 | }); 35 | 36 | it("can be matched by 'to' address for direct call()", async () => { 37 | await mockNode.forCall(CONTRACT_ADDRESS) 38 | .thenReturn('string', 'mock result'); 39 | 40 | const web3 = new Web3(mockNode.url); 41 | const matchingResult = await web3.eth.call({ to: CONTRACT_ADDRESS }).catch(e => e); 42 | const nonMatchingResult = await web3.eth.call({ to: OTHER_ADDRESS }).catch(e => e); 43 | 44 | expect(decodeAbi(['string'], matchingResult)).to.deep.equal(["mock result"]); 45 | expect(nonMatchingResult).to.be.instanceOf(Error); 46 | }); 47 | 48 | it("can be matched by function signature for direct call()", async () => { 49 | await mockNode.forCall() 50 | .forFunction('function foobar(string, bool)') // Function sig, but not normalized 51 | .thenReturn('string', 'mock result'); 52 | 53 | const web3 = new Web3(mockNode.url); 54 | const matchingResult = await web3.eth.call({ 55 | to: CONTRACT_ADDRESS, 56 | data: '0x7fddde58' + encodeAbi(['string', 'bool'], ['test', true]).slice(2) // Manually calculated sig hash 57 | }).catch(e => e); 58 | const nonMatchingResult = await web3.eth.call({ 59 | to: CONTRACT_ADDRESS, 60 | data: '0x99999999' + encodeAbi(['string', 'bool'], ['test', true]).slice(2) 61 | }).catch(e => e); 62 | 63 | expect(decodeAbi(['string'], matchingResult)).to.deep.equal(["mock result"]); 64 | expect(nonMatchingResult).to.be.instanceOf(Error); 65 | }); 66 | 67 | it("can be matched by function params for direct call()", async () => { 68 | await mockNode.forCall() 69 | .withParams(['string', 'bool'], ['test', true]) 70 | .thenReturn('string', 'mock result'); 71 | 72 | const web3 = new Web3(mockNode.url); 73 | const matchingResult = await web3.eth.call({ 74 | to: CONTRACT_ADDRESS, 75 | data: '0x7fddde58' + encodeAbi(['string', 'bool'], ['test', true]).slice(2) 76 | }).catch(e => e); 77 | const nonMatchingResult = await web3.eth.call({ 78 | to: CONTRACT_ADDRESS, 79 | data: '0x7fddde58' + encodeAbi(['string', 'bool'], ['other', false]).slice(2) 80 | }).catch(e => e); 81 | 82 | expect(decodeAbi(['string'], matchingResult)).to.deep.equal(["mock result"]); 83 | expect(nonMatchingResult).to.be.instanceOf(Error); 84 | }); 85 | 86 | it("can infer function matching/return types from the signature for direct call()", async () => { 87 | await mockNode.forCall() 88 | .forFunction("function foobar(bool, string) returns (int256)") 89 | .withParams([true, 'test']) 90 | .thenReturn([1234]); 91 | 92 | const web3 = new Web3(mockNode.url); 93 | const matchingResult = await web3.eth.call({ 94 | to: CONTRACT_ADDRESS, 95 | data: '0x73eac5b1' + encodeAbi(['bool', 'string'], [true, 'test']).slice(2) 96 | }).catch(e => e); 97 | const nonMatchingResult = await web3.eth.call({ 98 | to: CONTRACT_ADDRESS, 99 | data: '0x7fddde58' + encodeAbi(['bool', 'string'], [false, 'other']).slice(2) 100 | }).catch(e => e); 101 | 102 | expect(decodeAbi(['int256'], matchingResult)[0].toNumber()).to.equal(1234); 103 | expect(nonMatchingResult).to.be.instanceOf(Error); 104 | }); 105 | 106 | it("can mock calls through web3.js contract instances", async () => { 107 | await mockNode.forCall() 108 | .forFunction("function foobar(bool, string) returns (int256)") 109 | .withParams([true, 'test']) 110 | .thenReturn([1234]); 111 | 112 | const web3 = new Web3(mockNode.url); 113 | const contract = new web3.eth.Contract([{ 114 | type: 'function', 115 | name: 'foobar', 116 | inputs: [{ name: 'a', type: 'bool' }, { name: 'b', type: 'string' }], 117 | outputs: [{ name: 'result', type: 'int256' }] 118 | }], CONTRACT_ADDRESS); 119 | 120 | const matchingResult = await contract.methods.foobar(true, 'test').call().catch((e: any) => e); 121 | const nonMatchingResult = await contract.methods.foobar(false, 'other').call().catch((e: any) => e); 122 | 123 | expect(matchingResult).to.equal('1234'); 124 | expect(nonMatchingResult).to.be.instanceOf(Error); 125 | }); 126 | 127 | it("can reject contract calls with a custom error", async () => { 128 | await mockNode.forCall().thenRevert("Mock error"); 129 | 130 | const web3 = new Web3(mockNode.url); 131 | const errorResult = await web3.eth.call({ to: CONTRACT_ADDRESS }).catch(e => e); 132 | 133 | expect(errorResult).to.be.instanceOf(Error); 134 | expect(errorResult.message).to.equal( 135 | "Returned error: VM Exception while processing transaction: revert Mock error" 136 | ); 137 | }); 138 | 139 | it("can be mocked to close the connection", async () => { 140 | await mockNode.forCall().thenCloseConnection(); 141 | 142 | const web3 = new Web3(mockNode.url); 143 | const result = await web3.eth.call({ to: CONTRACT_ADDRESS }).catch(e => e); 144 | 145 | expect(result).to.be.instanceOf(Error); 146 | expect(result.message).to.include('CONNECTION ERROR'); 147 | }); 148 | 149 | it("can be mocked to timeout", async () => { 150 | await mockNode.forCall().thenTimeout(); 151 | 152 | const web3 = new Web3(mockNode.url); 153 | const result = await Promise.race([ 154 | web3.eth.call({ to: CONTRACT_ADDRESS }).catch(e => e), 155 | delay(500).then(() => 'timeout') 156 | ]); 157 | 158 | expect(result).to.equal('timeout'); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mockthereum [![Build Status](https://github.com/httptoolkit/mockthereum/workflows/CI/badge.svg)](https://github.com/httptoolkit/mockthereum/actions) [![Available on NPM](https://img.shields.io/npm/v/mockthereum.svg)](https://npmjs.com/package/mockthereum) 2 | 3 | > _Part of [HTTP Toolkit](https://httptoolkit.tech): powerful tools for building, testing & debugging HTTP(S), Ethereum, IPFS, and more_ 4 | 5 | Mockthereum lets you build a fake Ethereum node, or proxy traffic to a real Ethereum node, to inspect & mock all Ethereum interactions made by any Ethereum client website or application. 6 | 7 | --- 8 | 9 | :warning: _Mockthereum is still new & rapidly developing!_ :warning: 10 | 11 | _Everything described here works today, but there's lots more to come, and some advanced use cases may run into rough edges. If you hit any problems or missing features, please [open an issue](https://github.com/httptoolkit/mockthereum/issues/new)._ 12 | 13 | --- 14 | 15 | ## Example 16 | 17 | ```typescript 18 | import * as Mockthereum from 'mockthereum' 19 | 20 | // Use any real Ethereum client: 21 | import Web3 from 'web3'; 22 | 23 | // Parameters for some real Web3 contract: 24 | const CONTRACT_ADDRESS = "0x..."; 25 | const JSON_CONTRACT_ABI = { /* ... */ }; 26 | 27 | describe("Mockthereum", () => { 28 | 29 | // Create a mock Ethereum node: 30 | const mockNode = Mockthereum.getLocal(); 31 | 32 | // Start & stop your mock node to reset state between tests: 33 | beforeEach(() => mockNode.start()); 34 | afterEach(() => mockNode.stop()); 35 | 36 | it("lets you mock behaviour and assert on Ethereum interactions", async () => { 37 | // Mock any address balance 38 | await mockNode.forBalance('0x0000000000000000000000000000000000000001') 39 | .thenReturn(1000); 40 | 41 | // Mock any contract's function call: 42 | const mockedFunction = await mockNode.forCall(CONTRACT_ADDRESS) // Match any contract address 43 | // Optionally, match specific functions and parameters: 44 | .forFunction('function getText(string key) returns (string)') 45 | .withParams(["test"]) 46 | // Mock contract results: 47 | .thenReturn('Mock result'); 48 | 49 | // Real code to make client requests to the Ethereum node: 50 | const web3 = new Web3(mockNode.url); 51 | 52 | const walletBalance = await web3.eth.getBalance('0x0000000000000000000000000000000000000001'); 53 | expect(walletBalance).to.equal("1000"); // Returns our mocked wallet balance 54 | 55 | const contract = new web3.eth.Contract(JSON_CONTRACT_ABI, CONTRACT_ADDRESS); 56 | const contractResult = await contract.methods.getText("test").call(); 57 | 58 | // Check contract call returns our fake contract result: 59 | expect(contractResult).to.equal("Mock result"); 60 | 61 | // Assert on inputs, to check we saw the contract calls we expected: 62 | const mockedCalls = await mockedFunction.getRequests(); 63 | expect(mockedCalls.length).to.equal(1); 64 | 65 | expect(mockedCalls[0]).to.deep.include({ 66 | // Examine full interaction data, included decoded parameters etc: 67 | to: CONTRACT_ADDRESS, 68 | params: ["test"] 69 | }); 70 | }); 71 | }); 72 | ``` 73 | 74 | ## Getting Started 75 | 76 | First, install Mockthereum: 77 | 78 | ```bash 79 | npm install --save-dev mockthereum 80 | ``` 81 | 82 | Once you've installed the library, you'll want to use it in your test or automation code. To do so you need to: 83 | 84 | * Create an new Mockthereum node 85 | * Start the node, to make it listen for requests (and stop it when you're done) 86 | * Use the URL of the Mockthereum node as your Ethereum provider URL 87 | * Define some rules to mock behaviour 88 | 89 | ### Creating a Mockthereum Node 90 | 91 | To create a node in Node.js, you can simply call `Mockthereum.getLocal()` and you're done. 92 | 93 | In many cases though, to test a web application you'll want to run your tests inside a browser, and create & manage your mock Ethereum node there too. It's not possible to launch a node from inside a browser, but Mockthereum provides a separate admin server you can run, which will host your mock Ethereum node externally. 94 | 95 | Once your admin server is running, you can use the exact same code as for Node.js, but each method call is transparently turned into a remote-control call to the admin server. 96 | 97 | To do this, you just need to run the admin server before you start your tests, and stop it afterwards. You can do that in one of two ways: 98 | 99 | * You can run your test suite using the provided launch helper: 100 | ``` 101 | mockthereum -c 102 | ``` 103 | This will start & stop the admin server automatically before and after your tests. 104 | * Or you can launch the admin server programmatically like so: 105 | ```javascript 106 | import * as Mockthereum from 'mockthereum'; 107 | 108 | const adminServer = Mockthereum.getAdminServer(); 109 | adminServer.start().then(() => 110 | console.log('Admin server started') 111 | ); 112 | ``` 113 | 114 | Note that as this is a universal library (it works in Node.js & browsers) this code does reference some Node.js modules & globals in a couple of places. If you're using Mockthereum from inside a browser, this needs to be handled by your bundler. In many bundlers this will be handled automatically, but if it's not you may need to enable node polyfills for this. In Webpack that usually means enabling [node-polyfill-webpack-plugin](https://www.npmjs.com/package/node-polyfill-webpack-plugin), or in ESBuild you'll want the [`@esbuild-plugins/node-modules-polyfill`](https://www.npmjs.com/package/@esbuild-plugins/node-modules-polyfill) and [`@esbuild-plugins/node-globals-polyfill`](https://www.npmjs.com/package/@esbuild-plugins/node-globals-polyfill) plugins. 115 | 116 | Once you have an admin server running, you can call `Mockthereum.getLocal()` in the browser in exactly the same way as in Node.js, and it will automatically find & use the local admin server to create a mock Ethereum node. 117 | 118 | ### Starting & stopping your Mockthereum node 119 | 120 | Nodes expose `.start()` and `.stop()` methods to start & stop the node. You should call `.start()` before you use the node, call `.stop()` when you're done with it, and in both cases wait for the promise that's returned to ensure everything is completed before continuing. 121 | 122 | In automation, you'll want to create the node and start it immediately, and only stop it at shutdown. In testing environments it's usually better to start & stop the node between tests, like so: 123 | 124 | ```javascript 125 | import * as Mockthereum from 'mockthereum'; 126 | 127 | const mockNode = Mockthereum.getLocal(); 128 | 129 | describe("A suite of tests", () => { 130 | 131 | beforeEach(async () => { 132 | await mockNode.start(); 133 | }); 134 | 135 | afterEach(async () => { 136 | await mockNode.stop(); 137 | }); 138 | 139 | it("A single test", () => { 140 | // ... 141 | }); 142 | 143 | }); 144 | ``` 145 | 146 | ### Using your Mockthereum Node 147 | 148 | To use the Mockthereum node instead of connecting to your real provider, just use the HTTP URL exposed by `mockNode.url` as your Ethereum provider URL. 149 | 150 | For example, for Web3.js: 151 | 152 | ```javascript 153 | import Web3 from "web3"; 154 | 155 | const web3 = new Web3(mockNode.url); 156 | 157 | // Now use web3 as normal, and all interactions will be sent to the mock node instead of 158 | // any real Ethereum node, and so will not touch the real Ethereum network (unless you 159 | // explicitly proxy them - see 'Proxying Ethereum Traffic' below). 160 | ``` 161 | 162 | ### Defining mock rules 163 | 164 | Once you have a mock node, you can define rules to mock behaviour, allowing you to precisely control the Ethereum environment your code runs in, and test a variety of scenarios isolated from the real Ethereum network. 165 | 166 | To define a rule, once you have a mock node, call one of the `.forX()` methods to start defining the behaviour for a specific interaction through chained method calls, and call a `.thenX()` rule at the end (and wait for the returned promise) to complete the rule an activate that behaviour. 167 | 168 | There's many interactions that can be mocked with many behaviours, here's some examples: 169 | 170 | ```javascript 171 | // Mock the balance of a wallet: 172 | mockNode.forBalance('0x123412341234...') 173 | .thenReturn(1000); 174 | 175 | // Mock a fixed result for a contract call: 176 | mockNode.forCall(CONTRACT_ADDRESS) 177 | .forFunction("function foobar(bool, string) returns (int256)") 178 | .withParams([true, 'test']) 179 | .thenReturn([1234]); 180 | 181 | // Mock transactions, to test transaction rejection: 182 | mockNode.forSendTransactionTo(WALLET_ADDRESS) 183 | .thenRevert(); 184 | 185 | // Simulate timeouts and connection issues: 186 | mockNode.forBlockNumber() 187 | .thenTimeout(); 188 | ``` 189 | 190 | For the full list of interactions & behaviours, see the [detailed API Reference](https://httptoolkit.github.io/mockthereum/). 191 | 192 | ### Examining received requests 193 | 194 | In addition to defining behaviours, you can also examine the requests that have been received by the mock node, to verify that expected traffic is being received. 195 | 196 | To do so, call either `mockNode.getSeenRequests()` or `mockNode.getSeenMethodCalls(methodName)` which returns a promise that resolves to an array of seen requests - each one with a `method` property (the Ethereum method name, like `eth_getBalance`), a `parameters` property (the parameters passed to that method), and a `rawRequest` property (the full raw HTTP request details). 197 | 198 | For example, you can log all seen requests like so: 199 | 200 | ```javascript 201 | mockNode.getSeenRequests().then(requests => { 202 | requests.forEach(request => { 203 | console.log(`${request.method}: ${JSON.stringify(request.parameters)}`); 204 | }); 205 | }); 206 | ``` 207 | 208 | In addition, methods to mock contract & transaction behaviour (e.g. rules defined with `forCall()` or `forSendTransaction()`) return a mocked contract from the promise returned by every `.thenX()` method, which can be used directly to query the interactions with that specific rule. 209 | 210 | Each interaction is returned as an object with `to`, `from`, `value`, `params` and `rawRequest` fields. 211 | 212 | For example: 213 | 214 | ```javascript 215 | const mockedContract = await mockNode.forCall(CONTRACT_ADDRESS) 216 | .forFunction("function foobar(bool, string) returns (int256)") 217 | .withParams([true, 'test']) 218 | .thenReturn([1234]); 219 | 220 | // ...Call the above contract a few times... 221 | 222 | mockedContract.getRequests().then(requests => { 223 | requests.forEach(request => { 224 | console.log(`${request.from}->${request.to}: ${request.value} (${JSON.stringify(request.params)})`); 225 | }); 226 | }); 227 | ``` 228 | 229 | ### Proxying Ethereum traffic 230 | 231 | By default Mockthereum will mock all interactions with default values (rejecting calls & transactions, reporting all wallets as empty, and returning default values for all query methods like `eth_gasPrice`) but you can change this to proxy traffic to a real Ethereum node instead. 232 | 233 | By doing so, you can use Mockthereum as an intercepting proxy - returning real responses from the network for most cases, but allowing specific interactions to be mocked in isolation, and making it possible to query the list of interactions that were made. 234 | 235 | To do this, pass `unmatchedRequests: { proxyTo: "a-real-ethereum-node-HTTP-url" }` as an option when creating your mock Ethereum node. This will disable the default stub responses, and proxy all unmatched requests to the given node instead. For example: 236 | 237 | ```javascript 238 | import * as Mockthereum from 'mockthereum' 239 | const mockNode = Mockthereum.getLocal({ 240 | unmatchedRequests: { proxyTo: "http://localhost:30303" } 241 | }); 242 | mockNode.start(); 243 | ``` 244 | 245 | This only changes the unmatched request behaviour, and all other methods will continue to define behaviour and query seen request data as normal. 246 | 247 | ## API Reference Documentation 248 | 249 | For more details, see the [Mockthereum reference docs](https://httptoolkit.github.io/mockthereum/). 250 | 251 | --- 252 | 253 | _This‌ ‌project‌ ‌has‌ ‌received‌ ‌funding‌ ‌from‌ ‌the‌ ‌European‌ ‌Union’s‌ ‌Horizon‌ ‌2020‌‌ research‌ ‌and‌ ‌innovation‌ ‌programme‌ ‌within‌ ‌the‌ ‌framework‌ ‌of‌ ‌the‌ ‌NGI-POINTER‌‌ Project‌ ‌funded‌ ‌under‌ ‌grant‌ ‌agreement‌ ‌No‌ 871528._ 254 | 255 | ![The NGI logo and EU flag](./ngi-eu-footer.png) 256 | -------------------------------------------------------------------------------- /src/mock-node.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from 'mockttp'; 7 | import { CallRuleBuilder, TransactionRuleBuilder } from './contract-rule-builder'; 8 | import { RpcCallMatcher, RpcErrorResponseHandler, RpcResponseHandler } from './jsonrpc'; 9 | import { BalanceRuleBuilder, BlockNumberRuleBuilder, GasPriceRuleBuilder } from './single-value-rule-builders'; 10 | 11 | export interface MockthereumOptions { 12 | /** 13 | * Specify the behaviour of unmatched requests. 14 | * 15 | * By default this is set to `stub`, in which case default responses will be 16 | * returned, emulating a constantly available but empty node: all queries 17 | * will return no data or zeros, and transactions to all unmocked addresses 18 | * will fail. 19 | * 20 | * Alternatively, this can be set to an object including a `proxyTo` property, 21 | * defining the URL of an Ethereum RPC node to which unmatched requests should be 22 | * forwarded. In this case all default behaviours will be disabled, and all 23 | * unmatched requests will receive real responses from that upstream node. 24 | */ 25 | unmatchedRequests?: 26 | | 'stub' 27 | | { proxyTo: string } 28 | } 29 | 30 | /** 31 | * A Mockthereum node provides default behaviours and allows defining custom behaviour 32 | * rules to simulate interactions with the Ethereum network without requiring a full 33 | * node, access to the real Ethereum network, or real transactions with the delays and 34 | * costs that might involve. 35 | * 36 | * This should not be created directly: instead, call then `getLocal()` or `getRemote()` 37 | * methods exported from this module. 38 | * 39 | * Once you have a Mockthereum node, you can start defining rules using any of the 40 | * `forX()` methods. Each method returns a rule builder, allowing you to add extra 41 | * matching constraints, followed by a `thenX()` final method which enables the rule, 42 | * returning a promise that resolves once the rule is constructed and active. 43 | */ 44 | export class MockthereumNode { 45 | 46 | constructor( 47 | private mockttpServer: Mockttp.Mockttp, 48 | private options: MockthereumOptions = {} 49 | ) {} 50 | 51 | private seenRequests: Mockttp.CompletedRequest[] = []; 52 | 53 | /** 54 | * The node must be started before use. Starting the node resets it, removing any 55 | * rules that may have been added previously and configuring default behaviours 56 | * for unmatched requests. 57 | * 58 | * This method returns a promise, which you should wait for to ensure the node 59 | * is fully started before using it. 60 | */ 61 | async start() { 62 | this.reset(); 63 | await this.mockttpServer.start(); 64 | await this.addBaseRules(); 65 | } 66 | 67 | /** 68 | * Stop the node when you're finished with it to close down the underlying server and 69 | * any remaining connections. 70 | * 71 | * This method returns a promise, which you should wait for to ensure the node 72 | * is fully stopped, especially if you intend to start it again later. 73 | */ 74 | stop() { 75 | return this.mockttpServer.stop(); 76 | } 77 | 78 | reset() { 79 | this.seenRequests = []; 80 | this.mockttpServer.reset(); 81 | } 82 | 83 | /** 84 | * Get the URL for this Mockthereum node. You can pass this directly to libraries like 85 | * Web3 as your Ethereum RPC endpoint to intercept all Web3 Ethereum interactions. 86 | */ 87 | get url() { 88 | return this.mockttpServer.url; 89 | } 90 | 91 | private async addBaseRules() { 92 | await Promise.all([ 93 | this.mockttpServer.on('request', this.onRequest), 94 | 95 | ...(!this.options.unmatchedRequests || this.options.unmatchedRequests === 'stub' 96 | ? [ 97 | this.mockttpServer.addRequestRule({ 98 | matchers: [new RpcCallMatcher('eth_call')], 99 | priority: Mockttp.RulePriority.FALLBACK, 100 | handler: new RpcErrorResponseHandler( 101 | "No Mockthereum rules found matching Ethereum contract call" 102 | ) 103 | }), 104 | this.mockttpServer.addRequestRule({ 105 | matchers: [new RpcCallMatcher('eth_sendTransaction')], 106 | priority: Mockttp.RulePriority.FALLBACK, 107 | handler: new RpcErrorResponseHandler( 108 | "No Mockthereum rules found matching Ethereum transaction" 109 | ) 110 | }), 111 | this.mockttpServer.addRequestRule({ 112 | matchers: [new RpcCallMatcher('eth_sendRawTransaction')], 113 | priority: Mockttp.RulePriority.FALLBACK, 114 | handler: new RpcErrorResponseHandler( 115 | "No Mockthereum rules found matching Ethereum transaction" 116 | ) 117 | }), 118 | this.mockttpServer.addRequestRule({ 119 | matchers: [new RpcCallMatcher('eth_getTransactionReceipt')], 120 | priority: Mockttp.RulePriority.FALLBACK, 121 | handler: new RpcResponseHandler(null) 122 | }), 123 | this.mockttpServer.addRequestRule({ 124 | matchers: [new RpcCallMatcher('eth_getBalance')], 125 | priority: Mockttp.RulePriority.FALLBACK, 126 | handler: new RpcResponseHandler("0x0") 127 | }), 128 | this.mockttpServer.addRequestRule({ 129 | matchers: [new RpcCallMatcher('eth_blockNumber')], 130 | priority: Mockttp.RulePriority.FALLBACK, 131 | handler: new RpcResponseHandler("0x1") 132 | }), 133 | this.mockttpServer.addRequestRule({ 134 | matchers: [new RpcCallMatcher('eth_getBlockByNumber')], 135 | priority: Mockttp.RulePriority.FALLBACK, 136 | handler: new RpcResponseHandler(null) 137 | }), 138 | this.mockttpServer.addRequestRule({ 139 | matchers: [new RpcCallMatcher('eth_gasPrice')], 140 | priority: Mockttp.RulePriority.FALLBACK, 141 | handler: new RpcResponseHandler(`0x${(1000).toString(16)}`) 142 | }) 143 | ] 144 | : [ 145 | this.mockttpServer.forUnmatchedRequest() 146 | .thenForwardTo(this.options.unmatchedRequests.proxyTo) 147 | ]) 148 | ]); 149 | } 150 | 151 | private onRequest = (request: Mockttp.CompletedRequest) => { 152 | this.seenRequests.push(request); 153 | }; 154 | 155 | /** 156 | * Mock all wallet balance queries, either for all addresses (by default) or for 157 | * one specific wallet address, if specified. 158 | * 159 | * This returns a rule builder that you can use to configure the rule. Call a 160 | * `thenX()` method and wait for the returned promise to complete the rule and 161 | * activate it. 162 | */ 163 | forBalance(address?: `0x${string}`) { 164 | return new BalanceRuleBuilder(address, this.mockttpServer.addRequestRule); 165 | } 166 | 167 | /** 168 | * Mock all contract calls, either for all contracts (by default) or for 169 | * one specific contract address, if specified. 170 | * 171 | * This returns a rule builder that you can use to configure the rule. Call a 172 | * `thenX()` method and wait for the returned promise to complete the rule and 173 | * activate it. 174 | */ 175 | forCall(address?: `0x${string}`) { 176 | return new CallRuleBuilder(address, this.mockttpServer.addRequestRule); 177 | } 178 | 179 | /** 180 | * Mock all sent transactions. 181 | * 182 | * This returns a rule builder that you can use to configure the rule. Call a 183 | * `thenX()` method and wait for the returned promise to complete the rule and 184 | * activate it. 185 | */ 186 | forSendTransaction() { 187 | return new TransactionRuleBuilder( 188 | undefined, 189 | this.mockttpServer.addRequestRule, 190 | this.addReceipt.bind(this) 191 | ); 192 | } 193 | 194 | /** 195 | * Mock all transactions sent to a specific address. 196 | * 197 | * This returns a rule builder that you can use to configure the rule. Call a 198 | * `thenX()` method and wait for the returned promise to complete the rule and 199 | * activate it. 200 | */ 201 | forSendTransactionTo(address: `0x${string}`) { 202 | return new TransactionRuleBuilder( 203 | address, 204 | this.mockttpServer.addRequestRule, 205 | this.addReceipt.bind(this) 206 | ); 207 | } 208 | 209 | private async addReceipt(id: string, receipt: Partial) { 210 | await this.mockttpServer.addRequestRule({ 211 | matchers: [new RpcCallMatcher('eth_getTransactionReceipt', [id])], 212 | handler: new RpcResponseHandler({ 213 | status: '0x1', 214 | transactionHash: id, 215 | blockNumber: '0x100', 216 | blockHash: '0x1', 217 | from: '0x0', 218 | to: '0x0', 219 | cumulativeGasUsed: '0x1', 220 | gasUsed: '0x1', 221 | effectiveGasPrice: '0x0', 222 | contractAddress: null, 223 | logs: [], 224 | logsBloom: '0x0', 225 | type: '0x0', 226 | ...receipt 227 | }) 228 | }); 229 | } 230 | 231 | /** 232 | * Mock all block number queries. 233 | * 234 | * This returns a rule builder that you can use to configure the rule. Call a 235 | * `thenX()` method and wait for the returned promise to complete the rule and 236 | * activate it. 237 | */ 238 | forBlockNumber() { 239 | return new BlockNumberRuleBuilder(this.mockttpServer.addRequestRule); 240 | } 241 | 242 | /** 243 | * Mock all gas price queries. 244 | * 245 | * This returns a rule builder that you can use to configure the rule. Call a 246 | * `thenX()` method and wait for the returned promise to complete the rule and 247 | * activate it. 248 | */ 249 | forGasPrice() { 250 | return new GasPriceRuleBuilder(this.mockttpServer.addRequestRule); 251 | } 252 | 253 | /** 254 | * Query the list of requests seen by this node. This returns a promise, resolving 255 | * to an array of objects containing `method` (the Ethereum method called), 256 | * `parameters` (the method parameters) and `rawRequest` (the full raw HTTP request data). 257 | */ 258 | getSeenRequests(): Promise> { 263 | return Promise.all(this.seenRequests.map(async (request) => { 264 | return { 265 | rawRequest: request, 266 | ...(await request.body.getJson()) 267 | }; 268 | })); 269 | } 270 | 271 | /** 272 | * Query the list of requests seen by this node for a specific method. 273 | * 274 | * This returns a promise, resolving to an array of objects containing `method` (the Ethereum 275 | * method called), `parameters` (the method parameters) and `rawRequest` (the full raw HTTP 276 | * request data). 277 | */ 278 | async getSeenMethodCalls(methodName: string) { 279 | return (await this.getSeenRequests()) 280 | .filter(({ method }) => method === methodName); 281 | } 282 | 283 | } 284 | 285 | /** 286 | * The type of the raw JSON response for a transaction receipt. 287 | * 288 | * Note that unlike Web3-Core's TransactionReceipt and similar, this is the raw data, so 289 | * does not include processed formats (e.g. numbers, instead of hex strings) etc. 290 | */ 291 | export interface RawTransactionReceipt { 292 | status: string; 293 | type: string; 294 | transactionHash: string; 295 | transactionIndex: string; 296 | blockHash: string; 297 | blockNumber: number; 298 | from: string; 299 | to: string; 300 | contractAddress?: string; 301 | cumulativeGasUsed: string; 302 | gasUsed: number; 303 | effectiveGasPrice: string; 304 | logs: never[]; // Not supported, for now 305 | } -------------------------------------------------------------------------------- /src/contract-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from 'mockttp'; 7 | import { v4 as uuid } from 'uuid'; 8 | 9 | import { encodeAbi, encodeFunctionSignature, parseFunctionSignature } from './abi'; 10 | import { RpcCallMatcher, RpcErrorProperties, RpcErrorResponseHandler, RpcResponseHandler } from './jsonrpc'; 11 | import { RawTransactionReceipt } from './mock-node'; 12 | import { MockedContract } from './mocked-contract'; 13 | 14 | class ContractRuleBuilder { 15 | 16 | constructor( 17 | private addRuleCallback: (rule: Mockttp.RequestRuleData) => Promise, 18 | protected matchers: Mockttp.matchers.RequestMatcher[] = [] 19 | ) {} 20 | 21 | private paramTypes: string[] | undefined; 22 | protected returnTypes: string[] | undefined; 23 | 24 | protected async buildRule( 25 | handler: Mockttp.requestHandlerDefinitions.RequestHandlerDefinition 26 | ): Promise { 27 | const mockedEndpoint = await this.addRuleCallback({ matchers: this.matchers, handler }); 28 | return new MockedContract(mockedEndpoint, this.paramTypes); 29 | } 30 | 31 | /** 32 | * Only match requests for a specific function, provided here as a function signature string. 33 | */ 34 | forFunction(signature: string) { 35 | const func = parseFunctionSignature(signature); 36 | this.paramTypes = func.inputs.map(i => i.type); 37 | this.returnTypes = signature.includes(" returns ") 38 | ? func.outputs?.map(i => i.type) 39 | : undefined; // When returns is missing, outputs is [], so we have to force undefine it 40 | 41 | const encodedSignature = encodeFunctionSignature(func); 42 | 43 | this.matchers.push(new Mockttp.matchers.CallbackMatcher(async (req) => { 44 | const jsonBody: any = await req.body.getJson(); 45 | return (jsonBody.params[0].data as string).startsWith(encodedSignature); 46 | })); 47 | return this; 48 | } 49 | 50 | /** 51 | * Only match requests that send certain parameters. You must provide both a types 52 | * and a parameters array, unless you've already called `forFunction()` and 53 | * provided the function signature there. 54 | */ 55 | withParams(params: Array): this; 56 | withParams(types: Array, params: Array): this; 57 | withParams(...args: [Array] | [Array, Array]) { 58 | const [types, params] = (args.length === 1) 59 | ? [this.paramTypes, args[0]] 60 | : args; 61 | 62 | if (!types) { 63 | throw new Error( 64 | "If no function signature was provided with forFunction, withParams must be called " + 65 | "with a paramTypes array as the first argument" 66 | ); 67 | } 68 | 69 | if (types) { 70 | this.paramTypes = types; 71 | } 72 | 73 | this.matchers.push(new Mockttp.matchers.CallbackMatcher(async (req) => { 74 | const jsonBody: any = await req.body.getJson(); 75 | return (jsonBody.params[0].data as string).slice(10) == encodeAbi(types, params).slice(2); 76 | })); 77 | return this; 78 | } 79 | 80 | /** 81 | * Timeout, accepting the request but never returning a response. 82 | * 83 | * This method completes the rule definition, and returns a promise that resolves to a MockedContract 84 | * once the rule is active. The MockedContract can be used later to query the seen requests that this 85 | * rule has matched. 86 | */ 87 | thenTimeout() { 88 | return this.buildRule(new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition()); 89 | } 90 | 91 | /** 92 | * Close the connection immediately after receiving the matching request, without sending any response. 93 | * 94 | * This method completes the rule definition, and returns a promise that resolves to a MockedContract 95 | * once the rule is active. The MockedContract can be used later to query the seen requests that this 96 | * rule has matched. 97 | */ 98 | thenCloseConnection() { 99 | return this.buildRule(new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition()); 100 | } 101 | 102 | } 103 | 104 | export class CallRuleBuilder extends ContractRuleBuilder { 105 | 106 | /** 107 | * This builder should not be constructed directly. Call `mockNode.forCall()` instead. 108 | */ 109 | constructor( 110 | targetAddress: 111 | | undefined // All contracts 112 | | `0x${string}`, // A specific to: address 113 | addRuleCallback: (rule: Mockttp.RequestRuleData) => Promise 114 | ) { 115 | if (targetAddress) { 116 | super(addRuleCallback, [new RpcCallMatcher('eth_call', [{ 117 | to: targetAddress 118 | }])]); 119 | } else { 120 | super(addRuleCallback, [new RpcCallMatcher('eth_call')]); 121 | } 122 | } 123 | 124 | /** 125 | * Return one or more values from the contract call. You must provide both a types and a values array, 126 | * unless you've already called `forFunction()` and provided a function signature there that 127 | * includes return types. 128 | * 129 | * This method completes the rule definition, and returns a promise that resolves to a MockedContract 130 | * once the rule is active. The MockedContract can be used later to query the seen requests that this 131 | * rule has matched. 132 | */ 133 | thenReturn(outputType: string, value: unknown): Promise; 134 | thenReturn(values: Array): Promise; 135 | thenReturn(value: unknown): Promise; 136 | thenReturn(outputTypes: Array, values: Array): Promise; 137 | thenReturn(...args: 138 | | [string, unknown] 139 | | [unknown[]] 140 | | [unknown] 141 | | [Array, Array] 142 | ): Promise { 143 | let types: Array; 144 | let values: Array; 145 | 146 | if (args.length === 1) { 147 | if (!this.returnTypes) { 148 | throw new Error( 149 | "thenReturn() must be called with an outputTypes array as the first argument, or " + 150 | "forFunction() must be called first with a return signature" 151 | ); 152 | } 153 | 154 | types = this.returnTypes; 155 | if (Array.isArray(args[0])) { 156 | values = args[0]; 157 | } else { 158 | values = [args[0]]; 159 | } 160 | } else if (!Array.isArray(args[0])){ 161 | types = [args[0]]; 162 | values = [args[1]]; 163 | } else { 164 | types = args[0]; 165 | values = args[1] as unknown[]; 166 | } 167 | 168 | return this.buildRule(new RpcResponseHandler(encodeAbi(types, values))); 169 | } 170 | 171 | /** 172 | * Return an error, rejecting the contract call with the provided error message. 173 | * 174 | * This method completes the rule definition, and returns a promise that resolves to a MockedContract 175 | * once the rule is active. The MockedContract can be used later to query the seen requests that this 176 | * rule has matched. 177 | */ 178 | thenRevert(errorMessage: string) { 179 | return this.buildRule(new RpcErrorResponseHandler( 180 | `VM Exception while processing transaction: revert ${errorMessage}`, { 181 | name: 'CallError', 182 | data: `0x08c379a0${ // String type prefix 183 | encodeAbi(['string'], [errorMessage]).slice(2) 184 | }` 185 | } 186 | )); 187 | } 188 | 189 | } 190 | 191 | export class TransactionRuleBuilder extends ContractRuleBuilder { 192 | 193 | /** 194 | * This builder should not be constructed directly. Call `mockNode.forSendTransaction()` or 195 | * `mockNode.forSendTransactionTo()` instead. 196 | */ 197 | constructor( 198 | targetAddress: 199 | | undefined // All contracts 200 | | `0x${string}`, // A specific to: address 201 | addRuleCallback: (rule: Mockttp.RequestRuleData) => Promise, 202 | addReceiptCallback: (id: string, receipt: Partial) => Promise 203 | ) { 204 | if (targetAddress) { 205 | super(addRuleCallback, [new RpcCallMatcher('eth_sendTransaction', [{ 206 | to: targetAddress 207 | }])]); 208 | } else { 209 | super(addRuleCallback, [new RpcCallMatcher('eth_sendTransaction')]); 210 | } 211 | 212 | this.addReceiptCallback = addReceiptCallback; 213 | } 214 | 215 | private addReceiptCallback: (id: string, receipt: Partial) => Promise; 216 | 217 | /** 218 | * Return a successful transaction submission, with a random transaction id, and provide the 219 | * given successfully completed transaction receipt when the transaction status is queried later. 220 | * 221 | * The receipt can be any subset of the Ethereum receipt fields, and default values for a successful 222 | * transaction will be used for any missing fields. 223 | * 224 | * This method completes the rule definition, and returns a promise that resolves to a MockedContract 225 | * once the rule is active. The MockedContract can be used later to query the seen requests that this 226 | * rule has matched. 227 | */ 228 | thenSucceed(receipt: Partial = {}) { 229 | return this.buildRule(new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition( 230 | async (req) => { 231 | // 64 char random hex id: 232 | const transactionId = `0x${uuid().replace(/-/g, '')}${uuid().replace(/-/g, '')}`; 233 | 234 | const body = await req.body.getJson() as { 235 | id: number; 236 | params: [{ 237 | from: string | undefined, 238 | to: string | undefined, 239 | }] 240 | }; 241 | 242 | await this.addReceiptCallback(transactionId, { 243 | status: '0x1', 244 | from: body.params[0].from, 245 | to: body.params[0].to, 246 | ...receipt 247 | }); 248 | 249 | return { 250 | headers: { 'transfer-encoding': 'chunked', 'connection': 'keep-alive' }, 251 | json: { 252 | jsonrpc: "2.0", 253 | id: body.id, 254 | result: transactionId 255 | } 256 | }; 257 | } 258 | )); 259 | } 260 | 261 | /** 262 | * Return a successful transaction submission, with a random transaction id, and provide the 263 | * given failed & revert transaction receipt when the transaction status is queried later. 264 | * 265 | * The receipt can be any subset of the Ethereum receipt fields, and default values for a failed 266 | * transaction will be used for any missing fields. 267 | * 268 | * This method completes the rule definition, and returns a promise that resolves to a MockedContract 269 | * once the rule is active. The MockedContract can be used later to query the seen requests that this 270 | * rule has matched. 271 | */ 272 | thenRevert(receipt: Partial = {}) { 273 | return this.buildRule(new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition( 274 | async (req) => { 275 | // 64 char random hex id: 276 | const transactionId = `0x${uuid().replace(/-/g, '')}${uuid().replace(/-/g, '')}`; 277 | 278 | const body = await req.body.getJson() as { 279 | id: number; 280 | params: [{ 281 | from: string | undefined, 282 | to: string | undefined, 283 | }] 284 | }; 285 | 286 | await this.addReceiptCallback(transactionId, { 287 | status: '0x', 288 | type: '0x2', 289 | from: body.params[0].from, 290 | to: body.params[0].to, 291 | ...receipt 292 | }); 293 | 294 | return { 295 | headers: { 'transfer-encoding': 'chunked', 'connection': 'keep-alive' }, 296 | json: { 297 | jsonrpc: "2.0", 298 | id: body.id, 299 | result: transactionId 300 | } 301 | }; 302 | } 303 | )); 304 | } 305 | 306 | /** 307 | * Reject the transaction submission immediately with the given error message and properties. 308 | * 309 | * This method completes the rule definition, and returns a promise that resolves to a MockedContract 310 | * once the rule is active. The MockedContract can be used later to query the seen requests that this 311 | * rule has matched. 312 | */ 313 | thenFailImmediately(errorMessage: string, errorProperties: RpcErrorProperties = {}) { 314 | return this.buildRule(new RpcErrorResponseHandler(errorMessage, errorProperties)); 315 | } 316 | 317 | } --------------------------------------------------------------------------------