diff --git a/.changeset/pre.json b/.changeset/pre.json index 56ee4ec57a..63192bb611 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -20,6 +20,7 @@ "@nomicfoundation/hardhat-web3-v4": "1.0.0", "@nomicfoundation/example-project": "3.0.0", "@ignored/hardhat-vnext": "2.0.0", + "@ignored/hardhat-vnext-chai-matchers": "2.0.0", "@ignored/hardhat-vnext-errors": "2.0.0", "@ignored/hardhat-vnext-ethers": "2.0.0", "@ignored/hardhat-vnext-keystore": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb694b5720..de85be8c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1428,6 +1428,9 @@ importers: '@ignored/hardhat-vnext': specifier: workspace:^3.0.0-next.16 version: link:../hardhat + '@ignored/hardhat-vnext-chai-matchers': + specifier: workspace:^3.0.0-next.12 + version: link:../hardhat-chai-matchers '@ignored/hardhat-vnext-ethers': specifier: workspace:^3.0.0-next.12 version: link:../hardhat-ethers @@ -1449,12 +1452,21 @@ importers: '@openzeppelin/contracts': specifier: 5.1.0 version: 5.1.0 + '@types/chai': + specifier: ^4.2.0 + version: 4.3.20 '@types/mocha': specifier: '>=9.1.0' version: 10.0.9 '@types/node': specifier: ^20.14.9 version: 20.17.1 + chai: + specifier: ^5.1.2 + version: 5.1.2 + ethers: + specifier: ^6.13.2 + version: 6.13.4 forge-std: specifier: foundry-rs/forge-std#v1.9.4 version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/1eea5bae12ae557d589f9f0f0edae2faa47cb262 @@ -1505,7 +1517,7 @@ importers: version: 5.3.0 debug: specifier: ^4.1.1 - version: 4.3.7 + version: 4.3.7(supports-color@8.1.1) enquirer: specifier: ^2.3.0 version: 2.4.1 @@ -1595,6 +1607,109 @@ importers: specifier: 7.7.1 version: 7.7.1(eslint@8.57.0)(typescript@5.5.4) + v-next/hardhat-chai-matchers: + dependencies: + '@ignored/hardhat-vnext': + specifier: workspace:^3.0.0-next.16 + version: link:../hardhat + '@ignored/hardhat-vnext-errors': + specifier: workspace:^3.0.0-next.15 + version: link:../hardhat-errors + '@ignored/hardhat-vnext-ethers': + specifier: workspace:^3.0.0-next.12 + version: link:../hardhat-ethers + '@ignored/hardhat-vnext-utils': + specifier: workspace:^3.0.0-next.15 + version: link:../hardhat-utils + '@types/chai-as-promised': + specifier: ^8.0.1 + version: 8.0.1 + chai: + specifier: ^5.1.2 + version: 5.1.2 + chai-as-promised: + specifier: ^8.0.0 + version: 8.0.0(chai@5.1.2) + deep-eql: + specifier: ^5.0.1 + version: 5.0.2 + ethers: + specifier: ^6.13.2 + version: 6.13.4 + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.3.0 + version: 4.4.1(eslint@8.57.0) + '@ignored/hardhat-vnext-mocha-test-runner': + specifier: workspace:^3.0.0-next.12 + version: link:../hardhat-mocha-test-runner + '@ignored/hardhat-vnext-node-test-reporter': + specifier: workspace:^3.0.0-next.15 + version: link:../hardhat-node-test-reporter + '@nomicfoundation/hardhat-test-utils': + specifier: workspace:^ + version: link:../hardhat-test-utils + '@types/chai': + specifier: ^4.2.0 + version: 4.3.20 + '@types/debug': + specifier: ^4.1.4 + version: 4.1.12 + '@types/deep-eql': + specifier: ^4.0.2 + version: 4.0.2 + '@types/mocha': + specifier: '>=9.1.0' + version: 10.0.10 + '@types/node': + specifier: ^20.14.9 + version: 20.17.1 + '@typescript-eslint/eslint-plugin': + specifier: ^7.7.1 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) + '@typescript-eslint/parser': + specifier: ^7.7.1 + version: 7.18.0(eslint@8.57.0)(typescript@5.5.4) + c8: + specifier: ^9.1.0 + version: 9.1.0 + eslint: + specifier: 8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: 9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-import-resolver-typescript: + specifier: ^3.6.1 + version: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-plugin-import: + specifier: 2.29.1 + version: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) + eslint-plugin-no-only-tests: + specifier: 3.1.0 + version: 3.1.0 + expect-type: + specifier: ^0.19.0 + version: 0.19.0 + mocha: + specifier: ^10.0.0 + version: 10.7.3 + prettier: + specifier: 3.2.5 + version: 3.2.5 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + tsx: + specifier: ^4.11.0 + version: 4.19.2 + typescript: + specifier: ~5.5.0 + version: 5.5.4 + typescript-eslint: + specifier: 7.7.1 + version: 7.7.1(eslint@8.57.0)(typescript@5.5.4) + v-next/hardhat-errors: dependencies: '@ignored/hardhat-vnext-utils': @@ -3387,6 +3502,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/events@3.0.3': resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==} @@ -3432,6 +3550,9 @@ packages: '@types/minimatch@5.1.2': resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + '@types/mocha@10.0.10': + resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + '@types/mocha@10.0.9': resolution: {integrity: sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==} @@ -3906,6 +4027,10 @@ packages: assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -4079,6 +4204,10 @@ packages: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} + chai@5.1.2: + resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} + engines: {node: '>=12'} + chalk@0.4.0: resolution: {integrity: sha512-sQfYDlfv2DGVtjdoQqxS0cEZDroyG8h6TamA6rvxwlrU5BaSLDx9xhatBYl2pxZ7gmpNaPFVwBtdGdu5rQ+tYQ==} engines: {node: '>=0.8.0'} @@ -4313,6 +4442,10 @@ packages: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4691,6 +4824,7 @@ packages: ethereumjs-abi@0.6.8: resolution: {integrity: sha512-Tx0r/iXI6r+lRsdvkFDlut0N08jWMnKRZ6Gkq+Nmw75lZe4e6o3EkSnkaBP5NF6+m5PTGAr9JP43N3LyeoglsA==} + deprecated: This library has been deprecated and usage is discouraged. ethereumjs-util@6.2.1: resolution: {integrity: sha512-W2Ktez4L01Vexijrm5EB6w7dg4n/TgpoYU4avuT5T3Vmnw/eCRtiBrJfQYS/DCSvDIOLn2k57GcHdeBcgVxAqw==} @@ -5472,6 +5606,9 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.1.2: + resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -5853,6 +5990,10 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + pbkdf2@3.1.2: resolution: {integrity: sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==} engines: {node: '>=0.12'} @@ -7292,7 +7433,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -7593,7 +7734,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -8172,6 +8313,8 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/deep-eql@4.0.2': {} + '@types/events@3.0.3': {} '@types/find-up@2.1.1': {} @@ -8217,6 +8360,8 @@ snapshots: '@types/minimatch@5.1.2': {} + '@types/mocha@10.0.10': {} + '@types/mocha@10.0.9': {} '@types/ms@0.7.34': {} @@ -8342,7 +8487,7 @@ snapshots: '@typescript-eslint/type-utils': 7.7.1(eslint@8.57.0)(typescript@5.5.4) '@typescript-eslint/utils': 7.7.1(eslint@8.57.0)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.7.1 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.2 @@ -8372,7 +8517,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.5.4 @@ -8385,7 +8530,7 @@ snapshots: '@typescript-eslint/types': 7.7.1 '@typescript-eslint/typescript-estree': 7.7.1(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.7.1 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.5.4 @@ -8428,7 +8573,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -8440,7 +8585,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.7.1(typescript@5.5.4) '@typescript-eslint/utils': 7.7.1(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -8488,7 +8633,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8503,7 +8648,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.7.1 '@typescript-eslint/visitor-keys': 7.7.1 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8634,7 +8779,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8771,6 +8916,8 @@ snapshots: assertion-error@1.1.0: {} + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} async@1.5.2: {} @@ -8957,6 +9104,11 @@ snapshots: chai: 4.5.0 check-error: 2.1.1 + chai-as-promised@8.0.0(chai@5.1.2): + dependencies: + chai: 5.1.2 + check-error: 2.1.1 + chai@4.5.0: dependencies: assertion-error: 1.1.0 @@ -8967,6 +9119,14 @@ snapshots: pathval: 1.1.1 type-detect: 4.1.0 + chai@5.1.2: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.2 + pathval: 2.0.0 + chalk@0.4.0: dependencies: ansi-styles: 1.0.0 @@ -9191,10 +9351,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.3.7(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -9213,6 +9369,8 @@ snapshots: dependencies: type-detect: 4.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -9494,7 +9652,7 @@ snapshots: eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 8.57.0 eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) @@ -9660,7 +9818,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -9945,7 +10103,7 @@ snapshots: follow-redirects@1.15.9(debug@4.3.7): optionalDependencies: - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) for-each@0.3.3: dependencies: @@ -10261,7 +10419,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.3.7 + debug: 4.3.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -10620,6 +10778,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + loupe@3.1.2: {} + lower-case@2.0.2: dependencies: tslib: 2.8.0 @@ -11026,6 +11186,8 @@ snapshots: pathval@1.1.1: {} + pathval@2.0.0: {} + pbkdf2@3.1.2: dependencies: create-hash: 1.2.0 diff --git a/scripts/check-v-next-npm-scripts.js b/scripts/check-v-next-npm-scripts.js index 6eb188ce1a..e66d307780 100644 --- a/scripts/check-v-next-npm-scripts.js +++ b/scripts/check-v-next-npm-scripts.js @@ -25,6 +25,12 @@ for (const dir of dirs) { continue; } + // TODO: This is a temporary solution because compiler downloads are not yet managed via a mutex. + // As a result, the compilation step must occur in the pretest script to prevent multiple compilers from being downloaded simultaneously. + if (dir.name === "hardhat-chai-matchers") { + continue; + } + const packageJsonPath = path.resolve(vNextDir, dir.name, "package.json"); const packageJson = require(packageJsonPath); diff --git a/v-next/example-project/hardhat.config.ts b/v-next/example-project/hardhat.config.ts index b65dca7018..3c284b0186 100644 --- a/v-next/example-project/hardhat.config.ts +++ b/v-next/example-project/hardhat.config.ts @@ -15,6 +15,7 @@ import HardhatKeystore from "@ignored/hardhat-vnext-keystore"; import HardhatViem from "@ignored/hardhat-vnext-viem"; import hardhatNetworkHelpersPlugin from "@ignored/hardhat-vnext-network-helpers"; import hardhatEthersPlugin from "@ignored/hardhat-vnext-ethers"; +import hardhatChaiMatchersPlugin from "@ignored/hardhat-vnext-chai-matchers"; util.inspect.defaultOptions.depth = null; @@ -156,6 +157,7 @@ const config: HardhatUserConfig = { hardhatNetworkHelpersPlugin, HardhatNodeTestRunner, HardhatViem, + hardhatChaiMatchersPlugin, ], paths: { tests: { diff --git a/v-next/example-project/package.json b/v-next/example-project/package.json index d882252ffe..9f04f620fa 100644 --- a/v-next/example-project/package.json +++ b/v-next/example-project/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@ignored/hardhat-vnext": "workspace:^3.0.0-next.16", + "@ignored/hardhat-vnext-chai-matchers": "workspace:^3.0.0-next.12", "@ignored/hardhat-vnext-ethers": "workspace:^3.0.0-next.12", "@ignored/hardhat-vnext-keystore": "workspace:^3.0.0-next.12", "@ignored/hardhat-vnext-mocha-test-runner": "workspace:^3.0.0-next.12", @@ -28,13 +29,16 @@ "@ignored/hardhat-vnext-node-test-runner": "workspace:^3.0.0-next.12", "@ignored/hardhat-vnext-viem": "workspace:^3.0.0-next.11", "@openzeppelin/contracts": "5.1.0", + "@types/chai": "^4.2.0", "@types/mocha": ">=9.1.0", "@types/node": "^20.14.9", + "chai": "^5.1.2", + "ethers": "^6.13.2", + "forge-std": "foundry-rs/forge-std#v1.9.4", "mocha": "^10.0.0", "prettier": "3.2.5", "rimraf": "^5.0.5", "typescript": "~5.5.0", - "viem": "^2.21.42", - "forge-std": "foundry-rs/forge-std#v1.9.4" + "viem": "^2.21.42" } } diff --git a/v-next/example-project/test/mocha/mocha-test.ts b/v-next/example-project/test/mocha/mocha-test.ts index e9e35fd3d0..dfbd589ccd 100644 --- a/v-next/example-project/test/mocha/mocha-test.ts +++ b/v-next/example-project/test/mocha/mocha-test.ts @@ -1,8 +1,29 @@ import assert from "node:assert/strict"; import { describe, it } from "mocha"; +import { expect } from "chai"; + +import { anyUint } from "@ignored/hardhat-vnext-chai-matchers/withArgs"; +import { PANIC_CODES } from "@ignored/hardhat-vnext-chai-matchers/panic"; +import hre from "@ignored/hardhat-vnext"; + describe("Mocha test", () => { it("should work", () => { assert.equal(1 + 1, 2); }); }); + +describe("Mocha test with chai-matchers", () => { + before(async () => { + await hre.network.connect(); + }); + + it("should import variables from the chai-matchers package", () => { + expect(anyUint).to.be.a("function"); + expect(PANIC_CODES.ASSERTION_ERROR).to.be.a("number"); + }); + + it("should have the hardhat additional matchers", () => { + expect("0x0000010AB").to.not.hexEqual("0x0010abc"); + }); +}); diff --git a/v-next/example-project/tsconfig.json b/v-next/example-project/tsconfig.json index f1eff0f4c7..51be52df3a 100644 --- a/v-next/example-project/tsconfig.json +++ b/v-next/example-project/tsconfig.json @@ -21,6 +21,9 @@ }, { "path": "../hardhat-viem" + }, + { + "path": "../hardhat-chai-matchers" } ] } diff --git a/v-next/hardhat-chai-matchers/.eslintrc.cjs b/v-next/hardhat-chai-matchers/.eslintrc.cjs new file mode 100644 index 0000000000..939317ab1c --- /dev/null +++ b/v-next/hardhat-chai-matchers/.eslintrc.cjs @@ -0,0 +1,3 @@ +const { createConfig } = require("../../config-v-next/eslint.cjs"); + +module.exports = createConfig(__filename); diff --git a/v-next/hardhat-chai-matchers/.gitignore b/v-next/hardhat-chai-matchers/.gitignore new file mode 100644 index 0000000000..6aa5402c62 --- /dev/null +++ b/v-next/hardhat-chai-matchers/.gitignore @@ -0,0 +1,5 @@ +# Node modules +/node_modules + +# Compilation output +/dist diff --git a/v-next/hardhat-chai-matchers/.prettierignore b/v-next/hardhat-chai-matchers/.prettierignore new file mode 100644 index 0000000000..dafdfc3e02 --- /dev/null +++ b/v-next/hardhat-chai-matchers/.prettierignore @@ -0,0 +1,4 @@ +/node_modules +/dist +/coverage +CHANGELOG.md diff --git a/v-next/hardhat-chai-matchers/LICENSE b/v-next/hardhat-chai-matchers/LICENSE new file mode 100644 index 0000000000..0781b4a819 --- /dev/null +++ b/v-next/hardhat-chai-matchers/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 Nomic Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/v-next/hardhat-chai-matchers/README.md b/v-next/hardhat-chai-matchers/README.md new file mode 100644 index 0000000000..da470627a0 --- /dev/null +++ b/v-next/hardhat-chai-matchers/README.md @@ -0,0 +1,3 @@ +# Hardhat Chai Matchers + +This plugin adds Ethereum-specific capabilities to the [Chai](https://chaijs.com/) assertion library, making your smart contract tests easy to write and read. diff --git a/v-next/hardhat-chai-matchers/package.json b/v-next/hardhat-chai-matchers/package.json new file mode 100644 index 0000000000..8656e020d6 --- /dev/null +++ b/v-next/hardhat-chai-matchers/package.json @@ -0,0 +1,86 @@ +{ + "name": "@ignored/hardhat-vnext-chai-matchers", + "version": "3.0.0-next.12", + "description": "Hardhat utils for testing", + "homepage": "https://github.com/nomicfoundation/hardhat/tree/v-next/v-next/hardhat-chai-matchers", + "repository": { + "type": "git", + "url": "https://github.com/NomicFoundation/hardhat", + "directory": "v-next/hardhat-chai-matchers" + }, + "author": "Nomic Foundation", + "license": "MIT", + "type": "module", + "types": "dist/src/index.d.ts", + "exports": { + ".": "./dist/src/index.js", + "./panic": "./dist/src/panic.js", + "./withArgs": "./dist/src/withArgs.js" + }, + "keywords": [ + "ethereum", + "smart-contracts", + "hardhat", + "testing" + ], + "scripts": { + "lint": "pnpm prettier --check && pnpm eslint", + "lint:fix": "pnpm prettier --write && pnpm eslint --fix", + "eslint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "prettier": "prettier \"**/*.{ts,js,md,json}\"", + "test": "node --import tsx/esm --test --test-reporter=@ignored/hardhat-vnext-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "test:only": "node --import tsx/esm --test --test-only --test-reporter=@ignored/hardhat-vnext-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "test:coverage": "c8 --reporter html --reporter text --all --exclude test --exclude src/internal/types.ts --exclude src/internal/ui/direct-user-interruption-manager.ts --src src node --import tsx/esm --test --test-reporter=@ignored/hardhat-vnext-node-test-reporter \"test/!(fixture-projects|helpers)/**/*.ts\"", + "pretest": "pnpm build && node --import tsx/esm ./test/helpers/pretest.ts", + "pretest:only": "pnpm build", + "build": "tsc --build .", + "prepublishOnly": "pnpm build", + "clean": "rimraf dist" + }, + "files": [ + "dist/src/", + "src/", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], + "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.3.0", + "@ignored/hardhat-vnext-mocha-test-runner": "workspace:^3.0.0-next.12", + "@ignored/hardhat-vnext-node-test-reporter": "workspace:^3.0.0-next.15", + "@nomicfoundation/hardhat-test-utils": "workspace:^", + "@types/chai": "^4.2.0", + "@types/debug": "^4.1.4", + "@types/deep-eql": "^4.0.2", + "@types/mocha": ">=9.1.0", + "@types/node": "^20.14.9", + "@typescript-eslint/eslint-plugin": "^7.7.1", + "@typescript-eslint/parser": "^7.7.1", + "c8": "^9.1.0", + "eslint": "8.57.0", + "eslint-config-prettier": "9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-no-only-tests": "3.1.0", + "expect-type": "^0.19.0", + "mocha": "^10.0.0", + "prettier": "3.2.5", + "rimraf": "^5.0.5", + "tsx": "^4.11.0", + "typescript": "~5.5.0", + "typescript-eslint": "7.7.1" + }, + "dependencies": { + "@ignored/hardhat-vnext-errors": "workspace:^3.0.0-next.15", + "@ignored/hardhat-vnext-utils": "workspace:^3.0.0-next.15", + "@types/chai-as-promised": "^8.0.1", + "chai-as-promised": "^8.0.0", + "deep-eql": "^5.0.1" + }, + "peerDependencies": { + "@ignored/hardhat-vnext": "workspace:^3.0.0-next.16", + "@ignored/hardhat-vnext-ethers": "workspace:^3.0.0-next.12", + "chai": "^5.1.2", + "ethers": "^6.13.2" + } +} diff --git a/v-next/hardhat-chai-matchers/src/index.ts b/v-next/hardhat-chai-matchers/src/index.ts new file mode 100644 index 0000000000..13b0e23ecb --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/index.ts @@ -0,0 +1,12 @@ +import type { HardhatPlugin } from "@ignored/hardhat-vnext/types/plugins"; + +import "./type-extensions.js"; + +const hardhatChaiMatchersPlugin: HardhatPlugin = { + id: "hardhat-chai-matchers", + hookHandlers: { + network: import.meta.resolve("./internal/hook-handlers/network.js"), + }, +}; + +export default hardhatChaiMatchersPlugin; diff --git a/v-next/hardhat-chai-matchers/src/internal/add-chai-matchers.ts b/v-next/hardhat-chai-matchers/src/internal/add-chai-matchers.ts new file mode 100644 index 0000000000..b7512fcc39 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/add-chai-matchers.ts @@ -0,0 +1,46 @@ +import { use } from "chai"; +import chaiAsPromised from "chai-as-promised"; + +import { supportAddressable } from "./matchers/addressable.js"; +import { supportBigNumber } from "./matchers/big-number.js"; +import { supportChangeEtherBalance } from "./matchers/changeEtherBalance.js"; +import { supportChangeEtherBalances } from "./matchers/changeEtherBalances.js"; +import { supportChangeTokenBalance } from "./matchers/changeTokenBalance.js"; +import { supportEmit } from "./matchers/emit.js"; +import { supportHexEqual } from "./matchers/hexEqual.js"; +import { supportProperAddress } from "./matchers/properAddress.js"; +import { supportProperHex } from "./matchers/properHex.js"; +import { supportProperPrivateKey } from "./matchers/properPrivateKey.js"; +import { supportReverted } from "./matchers/reverted/reverted.js"; +import { supportRevertedWith } from "./matchers/reverted/revertedWith.js"; +import { supportRevertedWithCustomError } from "./matchers/reverted/revertedWithCustomError.js"; +import { supportRevertedWithPanic } from "./matchers/reverted/revertedWithPanic.js"; +import { supportRevertedWithoutReason } from "./matchers/reverted/revertedWithoutReason.js"; +import { supportWithArgs } from "./matchers/withArgs.js"; + +export function addChaiMatchers(): void { + use(hardhatChaiMatchers); + use(chaiAsPromised); +} + +function hardhatChaiMatchers( + chai: Chai.ChaiStatic, + chaiUtils: Chai.ChaiUtils, +): void { + supportAddressable(chai.Assertion, chaiUtils); + supportBigNumber(chai.Assertion, chaiUtils); + supportEmit(chai.Assertion, chaiUtils); + supportHexEqual(chai.Assertion); + supportProperAddress(chai.Assertion); + supportProperHex(chai.Assertion); + supportProperPrivateKey(chai.Assertion); + supportChangeEtherBalance(chai.Assertion, chaiUtils); + supportChangeEtherBalances(chai.Assertion, chaiUtils); + supportChangeTokenBalance(chai.Assertion, chaiUtils); + supportReverted(chai.Assertion, chaiUtils); + supportRevertedWith(chai.Assertion, chaiUtils); + supportRevertedWithCustomError(chai.Assertion, chaiUtils); + supportRevertedWithPanic(chai.Assertion, chaiUtils); + supportRevertedWithoutReason(chai.Assertion, chaiUtils); + supportWithArgs(chai.Assertion, chaiUtils); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/constants.ts b/v-next/hardhat-chai-matchers/src/internal/constants.ts new file mode 100644 index 0000000000..481b77c834 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/constants.ts @@ -0,0 +1,13 @@ +export const ASSERTION_ABORTED = "hh-chai-matchers-assertion-aborted"; +export const PREVIOUS_MATCHER_NAME = "previousMatcherName"; + +export const CHANGE_ETHER_BALANCE_MATCHER = "changeEtherBalance"; +export const CHANGE_ETHER_BALANCES_MATCHER = "changeEtherBalances"; +export const CHANGE_TOKEN_BALANCE_MATCHER = "changeTokenBalance"; +export const CHANGE_TOKEN_BALANCES_MATCHER = "changeTokenBalances"; +export const EMIT_MATCHER = "emit"; +export const REVERTED_MATCHER = "reverted"; +export const REVERTED_WITH_MATCHER = "revertedWith"; +export const REVERTED_WITH_CUSTOM_ERROR_MATCHER = "revertedWithCustomError"; +export const REVERTED_WITH_PANIC_MATCHER = "revertedWithPanic"; +export const REVERTED_WITHOUT_REASON_MATCHER = "revertedWithoutReason"; diff --git a/v-next/hardhat-chai-matchers/src/internal/hook-handlers/network.ts b/v-next/hardhat-chai-matchers/src/internal/hook-handlers/network.ts new file mode 100644 index 0000000000..8341c9c83d --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/hook-handlers/network.ts @@ -0,0 +1,30 @@ +import type { + HookContext, + NetworkHooks, +} from "@ignored/hardhat-vnext/types/hooks"; +import type { + ChainType, + NetworkConnection, +} from "@ignored/hardhat-vnext/types/network"; + +import { addChaiMatchers } from "../add-chai-matchers.js"; + +let isInitialized = false; + +export default async (): Promise> => { + const handlers: Partial = { + async newConnection( + context: HookContext, + next: (context: HookContext) => Promise>, + ) { + if (!isInitialized) { + addChaiMatchers(); + isInitialized = true; + } + + return next(context); + }, + }; + + return handlers; +}; diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/addressable.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/addressable.ts new file mode 100644 index 0000000000..d046d6a283 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/addressable.ts @@ -0,0 +1,86 @@ +import { assertHardhatInvariant } from "@ignored/hardhat-vnext-errors"; +import { isAddress, isAddressable } from "ethers"; + +import { tryDereference } from "../utils/typed.js"; + +export function supportAddressable( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + const equalsFunction = override("eq", "equal", "not equal", chaiUtils); + Assertion.overwriteMethod("equals", equalsFunction); + Assertion.overwriteMethod("equal", equalsFunction); + Assertion.overwriteMethod("eq", equalsFunction); +} + +type Methods = "eq"; + +function override( + method: Methods, + name: string, + negativeName: string, + chaiUtils: Chai.ChaiUtils, +) { + return (_super: (...args: any[]) => any) => + overwriteAddressableFunction(method, name, negativeName, _super, chaiUtils); +} + +// ethers's Addressable have a .getAddress() that returns a Promise. We don't want to deal with async here, +// so we are looking for a sync way of getting the address. If an address was recovered, it is returned as a string, +// otherwise undefined is returned. +function tryGetAddressSync(value: any): string | undefined { + value = tryDereference(value, "address"); + + if (isAddressable(value)) { + if ("address" in value) { + value = value.address; + } else { + assertHardhatInvariant( + "target" in value, + "target property should exist in value", + ); + value = value.target; + } + } + + if (isAddress(value)) { + return value; + } else { + return undefined; + } +} + +function overwriteAddressableFunction( + functionName: Methods, + readableName: string, + readableNegativeName: string, + _super: (...args: any[]) => any, + chaiUtils: Chai.ChaiUtils, +) { + return function (this: Chai.AssertionStatic, ...args: any[]) { + const [actualArg, message] = args; + const expectedFlag = chaiUtils.flag(this, "object"); + + if (message !== undefined) { + chaiUtils.flag(this, "message", message); + } + + const actual = tryGetAddressSync(actualArg); + const expected = tryGetAddressSync(expectedFlag); + if ( + functionName === "eq" && + expected !== undefined && + actual !== undefined + ) { + this.assert( + expected === actual, + `expected '${expected}' to ${readableName} '${actual}'.`, + `expected '${expected}' to ${readableNegativeName} '${actual}'.`, + actual.toString(), + expected.toString(), + ); + } else { + _super.apply(this, args); + } + }; +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/big-number.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/big-number.ts new file mode 100644 index 0000000000..5ea5d61881 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/big-number.ts @@ -0,0 +1,279 @@ +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { toBigInt } from "@ignored/hardhat-vnext-utils/bigint"; +import { AssertionError } from "chai"; +import deepEqual from "deep-eql"; + +import { isBigInt } from "../utils/bigint.js"; + +export function supportBigNumber( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + const equalsFunction = override("eq", "equal", "not equal", chaiUtils); + Assertion.overwriteMethod("equals", equalsFunction); + Assertion.overwriteMethod("equal", equalsFunction); + Assertion.overwriteMethod("eq", equalsFunction); + + const gtFunction = override("gt", "be above", "be at most", chaiUtils); + Assertion.overwriteMethod("above", gtFunction); + Assertion.overwriteMethod("gt", gtFunction); + Assertion.overwriteMethod("greaterThan", gtFunction); + + const ltFunction = override("lt", "be below", "be at least", chaiUtils); + Assertion.overwriteMethod("below", ltFunction); + Assertion.overwriteMethod("lt", ltFunction); + Assertion.overwriteMethod("lessThan", ltFunction); + + const gteFunction = override("gte", "be at least", "be below", chaiUtils); + Assertion.overwriteMethod("least", gteFunction); + Assertion.overwriteMethod("gte", gteFunction); + Assertion.overwriteMethod("greaterThanOrEqual", gteFunction); + + const lteFunction = override("lte", "be at most", "be above", chaiUtils); + Assertion.overwriteMethod("most", lteFunction); + Assertion.overwriteMethod("lte", lteFunction); + Assertion.overwriteMethod("lessThanOrEqual", lteFunction); + + Assertion.overwriteChainableMethod(...createLengthOverride("length")); + Assertion.overwriteChainableMethod(...createLengthOverride("lengthOf")); + + Assertion.overwriteMethod("within", overrideWithin(chaiUtils)); + + Assertion.overwriteMethod("closeTo", overrideCloseTo(chaiUtils)); + Assertion.overwriteMethod("approximately", overrideCloseTo(chaiUtils)); +} + +function createLengthOverride( + method: string, +): [string, (...args: any[]) => any, (...args: any[]) => any] { + return [ + method, + function (_super: any) { + return function (this: Chai.AssertionPrototype, value: any) { + const actual = this._obj; + + if (isBigInt(value)) { + const sizeOrLength = + actual instanceof Map || actual instanceof Set ? "size" : "length"; + + const actualLength = toBigInt(actual[sizeOrLength]); + const expectedLength = toBigInt(value); + + this.assert( + actualLength === expectedLength, + `expected #{this} to have a ${sizeOrLength} of ${expectedLength.toString()} but got ${actualLength.toString()}`, + `expected #{this} not to have a ${sizeOrLength} of ${expectedLength.toString()} but got ${actualLength.toString()}`, + actualLength.toString(), + expectedLength.toString(), + ); + } else { + _super.apply(this, arguments); + } + }; + }, + function (_super: any) { + return function (this: any) { + _super.apply(this, arguments); + }; + }, + ]; +} + +type Methods = "eq" | "gt" | "lt" | "gte" | "lte"; + +function override( + method: Methods, + name: string, + negativeName: string, + chaiUtils: Chai.ChaiUtils, +) { + return (_super: (...args: any[]) => any) => + overwriteBigNumberFunction(method, name, negativeName, _super, chaiUtils); +} + +function overwriteBigNumberFunction( + functionName: Methods, + readableName: string, + readableNegativeName: string, + _super: (...args: any[]) => any, + chaiUtils: Chai.ChaiUtils, +) { + return function (this: Chai.AssertionStatic, ...args: any[]) { + const [actualArg, message] = args; + const expectedFlag = chaiUtils.flag(this, "object"); + + if (message !== undefined) { + chaiUtils.flag(this, "message", message); + } + + function compare(method: Methods, lhs: bigint, rhs: bigint): boolean { + if (method === "eq") { + return lhs === rhs; + } else if (method === "gt") { + return lhs > rhs; + } else if (method === "lt") { + return lhs < rhs; + } else if (method === "gte") { + return lhs >= rhs; + } else if (method === "lte") { + return lhs <= rhs; + } else { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.UNKNOWN_COMPARISON_OPERATION, + { + method, + }, + ); + } + } + + if (Boolean(chaiUtils.flag(this, "doLength")) && isBigInt(actualArg)) { + const sizeOrLength = + expectedFlag instanceof Map || expectedFlag instanceof Set + ? "size" + : "length"; + + if (expectedFlag[sizeOrLength] === undefined) { + _super.apply(this, args); + return; + } + + const expected = toBigInt(expectedFlag[sizeOrLength]); + const actual = toBigInt(actualArg); + + this.assert( + compare(functionName, expected, actual), + `expected #{this} to have a ${sizeOrLength} ${readableName.replace( + "be ", + "", + )} ${actual.toString()} but got ${expected}`, + `expected #{this} to have a ${sizeOrLength} ${readableNegativeName} ${actual.toString()}`, + expected, + actual, + ); + } else if (functionName === "eq" && Boolean(chaiUtils.flag(this, "deep"))) { + // "ssfi" stands for "start stack function indicator", it's a chai concept + // used to control which frames are included in the stack trace + // this pattern here was taken from chai's implementation of .deep.equal + const prevLockSsfi = chaiUtils.flag(this, "lockSsfi"); + + chaiUtils.flag(this, "lockSsfi", true); + + this.assert( + deepEqual(actualArg, expectedFlag, { comparator: deepEqualComparator }), + `expected ${util.inspect(expectedFlag)} to deeply equal ${util.inspect( + actualArg, + )}`, + `expected ${util.inspect( + expectedFlag, + )} to not deeply equal ${util.inspect(actualArg)}`, + null, + ); + + chaiUtils.flag(this, "lockSsfi", prevLockSsfi); + } else if (isBigInt(expectedFlag) || isBigInt(actualArg)) { + const expected = toBigInt(expectedFlag); + const actual = toBigInt(actualArg); + + this.assert( + compare(functionName, expected, actual), + `expected ${expected} to ${readableName} ${actual}.`, + `expected ${expected} to ${readableNegativeName} ${actual}.`, + actual.toString(), + expected.toString(), + ); + } else { + _super.apply(this, args); + } + }; +} + +function overrideWithin(chaiUtils: Chai.ChaiUtils) { + return (_super: (...args: any[]) => any) => + overwriteBigNumberWithin(_super, chaiUtils); +} + +function overwriteBigNumberWithin( + _super: (...args: any[]) => any, + chaiUtils: Chai.ChaiUtils, +) { + return function (this: Chai.AssertionStatic, ...args: any[]) { + const [startArg, finishArg] = args; + const expectedFlag = chaiUtils.flag(this, "object"); + + if (isBigInt(expectedFlag) || isBigInt(startArg) || isBigInt(finishArg)) { + const expected = toBigInt(expectedFlag); + const start = toBigInt(startArg); + const finish = toBigInt(finishArg); + this.assert( + start <= expected && expected <= finish, + `expected ${expected} to be within ${start}..${finish}`, + `expected ${expected} to not be within ${start}..${finish}`, + expected, + [start, finish], + ); + } else { + _super.apply(this, args); + } + }; +} + +function overrideCloseTo(chaiUtils: Chai.ChaiUtils) { + return (_super: (...args: any[]) => any) => + overwriteBigNumberCloseTo(_super, chaiUtils); +} + +function overwriteBigNumberCloseTo( + _super: (...args: any[]) => any, + chaiUtils: Chai.ChaiUtils, +) { + return function (this: Chai.AssertionStatic, ...args: any[]) { + const [actualArg, deltaArg] = args; + const expectedFlag = chaiUtils.flag(this, "object"); + + if ( + isBigInt(expectedFlag) || + isBigInt(actualArg) || + isBigInt(deltaArg) || + typeof actualArg === "number" + ) { + if (deltaArg === undefined) { + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError( + "the arguments to closeTo or approximately must be numbers, and a delta is required", + ); + } + + const expected = toBigInt(expectedFlag); + const actual = toBigInt(actualArg); + const delta = toBigInt(deltaArg); + + function abs(i: bigint): bigint { + return i < 0 ? BigInt(-1) * i : i; + } + + this.assert( + abs(expected - actual) <= delta, + `expected ${expected} to be close to ${actual} +/- ${delta}`, + `expected ${expected} not to be close to ${actual} +/- ${delta}`, + expected, + `A number between ${actual - delta} and ${actual + delta}`, + ); + } else { + _super.apply(this, args); + } + }; +} + +function deepEqualComparator(a: any, b: any): boolean | null { + try { + const normalizedA = toBigInt(a); + const normalizedB = toBigInt(b); + return normalizedA === normalizedB; + } catch (e) { + // use default comparator + return null; + } +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/changeEtherBalance.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/changeEtherBalance.ts new file mode 100644 index 0000000000..5e67c30762 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/changeEtherBalance.ts @@ -0,0 +1,138 @@ +import type { BalanceChangeOptions } from "../utils/balance.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { Addressable } from "ethers/address"; +import type { TransactionResponse } from "ethers/providers"; +import type { BigNumberish } from "ethers/utils"; + +import { assertHardhatInvariant } from "@ignored/hardhat-vnext-errors"; +import { numberToHexString } from "@ignored/hardhat-vnext-utils/hex"; +import { isObject } from "@ignored/hardhat-vnext-utils/lang"; +import { toBigInt } from "ethers/utils"; + +import { CHANGE_ETHER_BALANCE_MATCHER } from "../constants.js"; +import { getAddressOf } from "../utils/account.js"; +import { + assertCanBeConvertedToBigint, + assertIsNotNull, +} from "../utils/asserts.js"; +import { buildAssert } from "../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../utils/prevent-chaining.js"; + +export function supportChangeEtherBalance( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + CHANGE_ETHER_BALANCE_MATCHER, + function ( + this: any, + provider: EthereumProvider, + account: Addressable | string, + balanceChange: BigNumberish | ((change: bigint) => boolean), + options?: BalanceChangeOptions, + ) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + const subject = this._obj; + + preventAsyncMatcherChaining( + this, + CHANGE_ETHER_BALANCE_MATCHER, + chaiUtils, + ); + + const checkBalanceChange = ([actualChange, address]: [ + bigint, + string, + ]) => { + const assert = buildAssert(negated, checkBalanceChange); + + if (typeof balanceChange === "function") { + assert( + balanceChange(actualChange), + `Expected the ether balance change of "${address}" to satisfy the predicate, but it didn't (balance change: ${actualChange.toString()} wei)`, + `Expected the ether balance change of "${address}" to NOT satisfy the predicate, but it did (balance change: ${actualChange.toString()} wei)`, + ); + } else { + const expectedChange = toBigInt(balanceChange); + assert( + actualChange === expectedChange, + `Expected the ether balance of "${address}" to change by ${balanceChange.toString()} wei, but it changed by ${actualChange.toString()} wei`, + `Expected the ether balance of "${address}" NOT to change by ${balanceChange.toString()} wei, but it did`, + ); + } + }; + + const derivedPromise = Promise.all([ + getBalanceChange(provider, subject, account, options), + getAddressOf(account), + ]).then(checkBalanceChange); + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + this.promise = derivedPromise; + return this; + }, + ); +} + +export async function getBalanceChange( + provider: EthereumProvider, + transaction: + | TransactionResponse + | Promise + | (() => Promise | TransactionResponse), + account: Addressable | string, + options?: BalanceChangeOptions, +): Promise { + let txResponse: TransactionResponse; + + if (typeof transaction === "function") { + txResponse = await transaction(); + } else { + txResponse = await transaction; + } + + const txReceipt = await txResponse.wait(); + assertIsNotNull(txReceipt, "txReceipt"); + const txBlockNumber = txReceipt.blockNumber; + + const block = await provider.request({ + method: "eth_getBlockByHash", + params: [txReceipt.blockHash, false], + }); + + assertHardhatInvariant( + isObject(block) && + Array.isArray(block.transactions) && + block.transactions.length === 1, + "There should be only 1 transaction in the block", + ); + + const address = await getAddressOf(account); + + const balanceAfterHex = await provider.request({ + method: "eth_getBalance", + params: [address, numberToHexString(txBlockNumber)], + }); + + const balanceBeforeHex = await provider.request({ + method: "eth_getBalance", + params: [address, numberToHexString(txBlockNumber - 1)], + }); + + assertCanBeConvertedToBigint(balanceAfterHex); + assertCanBeConvertedToBigint(balanceBeforeHex); + + const balanceAfter = BigInt(balanceAfterHex); + const balanceBefore = BigInt(balanceBeforeHex); + + if (options?.includeFee !== true && address === txResponse.from) { + const gasPrice = txReceipt.gasPrice; + const gasUsed = txReceipt.gasUsed; + const txFee = gasPrice * gasUsed; + + return balanceAfter + txFee - balanceBefore; + } else { + return balanceAfter - balanceBefore; + } +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/changeEtherBalances.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/changeEtherBalances.ts new file mode 100644 index 0000000000..7f97ced1d4 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/changeEtherBalances.ts @@ -0,0 +1,188 @@ +import type { BalanceChangeOptions } from "../utils/balance.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { Addressable } from "ethers/address"; +import type { TransactionResponse } from "ethers/providers"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { toBigInt } from "ethers/utils"; + +import { CHANGE_ETHER_BALANCES_MATCHER } from "../constants.js"; +import { getAddressOf } from "../utils/account.js"; +import { assertIsNotNull } from "../utils/asserts.js"; +import { getAddresses, getBalances } from "../utils/balance.js"; +import { buildAssert } from "../utils/build-assert.js"; +import { ordinal } from "../utils/ordinal.js"; +import { preventAsyncMatcherChaining } from "../utils/prevent-chaining.js"; + +export function supportChangeEtherBalances( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + CHANGE_ETHER_BALANCES_MATCHER, + function ( + this: any, + provider: EthereumProvider, + accounts: Array, + balanceChanges: bigint[] | ((changes: bigint[]) => boolean), + options?: BalanceChangeOptions, + ) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + let subject = this._obj; + if (typeof subject === "function") { + subject = subject(); + } + + preventAsyncMatcherChaining( + this, + CHANGE_ETHER_BALANCES_MATCHER, + chaiUtils, + ); + + validateInput(this._obj, accounts, balanceChanges); + + const checkBalanceChanges = ([actualChanges, accountAddresses]: [ + bigint[], + string[], + ]) => { + const assert = buildAssert(negated, checkBalanceChanges); + + if (typeof balanceChanges === "function") { + assert( + balanceChanges(actualChanges), + "Expected the balance changes of the accounts to satisfy the predicate, but they didn't", + "Expected the balance changes of the accounts to NOT satisfy the predicate, but they did", + ); + } else { + assert( + actualChanges.every( + (change, ind) => change === toBigInt(balanceChanges[ind]), + ), + () => { + const lines: string[] = []; + actualChanges.forEach((change: bigint, i) => { + if (change !== toBigInt(balanceChanges[i])) { + lines.push( + `Expected the ether balance of ${ + accountAddresses[i] + } (the ${ordinal( + i + 1, + )} address in the list) to change by ${balanceChanges[ + i + ].toString()} wei, but it changed by ${change.toString()} wei`, + ); + } + }); + return lines.join("\n"); + }, + () => { + const lines: string[] = []; + actualChanges.forEach((change: bigint, i) => { + if (change === toBigInt(balanceChanges[i])) { + lines.push( + `Expected the ether balance of ${ + accountAddresses[i] + } (the ${ordinal( + i + 1, + )} address in the list) NOT to change by ${balanceChanges[ + i + ].toString()} wei, but it did`, + ); + } + }); + return lines.join("\n"); + }, + ); + } + }; + + const derivedPromise = Promise.all([ + getBalanceChanges(provider, subject, accounts, options), + getAddresses(accounts), + ]).then(checkBalanceChanges); + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + this.promise = derivedPromise; + return this; + }, + ); +} + +function validateInput( + obj: any, + accounts: Array, + balanceChanges: bigint[] | ((changes: bigint[]) => boolean), +) { + try { + if ( + Array.isArray(balanceChanges) && + accounts.length !== balanceChanges.length + ) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.ACCOUNTS_NUMBER_DIFFERENT_FROM_BALANCE_CHANGES, + { + accounts: accounts.length, + balanceChanges: balanceChanges.length, + }, + ); + } + } catch (e) { + // if the input validation fails, we discard the subject since it could + // potentially be a rejected promise + Promise.resolve(obj).catch(() => {}); + throw e; + } +} + +export async function getBalanceChanges( + provider: EthereumProvider, + transaction: TransactionResponse | Promise, + accounts: Array, + options?: BalanceChangeOptions, +): Promise { + const txResponse = await transaction; + + const txReceipt = await txResponse.wait(); + assertIsNotNull(txReceipt, "txReceipt"); + const txBlockNumber = txReceipt.blockNumber; + + const balancesAfter = await getBalances(provider, accounts, txBlockNumber); + const balancesBefore = await getBalances( + provider, + accounts, + txBlockNumber - 1, + ); + + const txFees = await getTxFees(accounts, txResponse, options); + + return balancesAfter.map( + (balance, ind) => balance + txFees[ind] - balancesBefore[ind], + ); +} + +async function getTxFees( + accounts: Array, + txResponse: TransactionResponse, + options?: BalanceChangeOptions, +): Promise { + return Promise.all( + accounts.map(async (account) => { + if ( + options?.includeFee !== true && + (await getAddressOf(account)) === txResponse.from + ) { + const txReceipt = await txResponse.wait(); + assertIsNotNull(txReceipt, "txReceipt"); + const gasPrice = txReceipt.gasPrice ?? txResponse.gasPrice; + const gasUsed = txReceipt.gasUsed; + const txFee = gasPrice * gasUsed; + + return txFee; + } + + return 0n; + }), + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/changeTokenBalance.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/changeTokenBalance.ts new file mode 100644 index 0000000000..a7b3b64e36 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/changeTokenBalance.ts @@ -0,0 +1,295 @@ +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { + Addressable, + BaseContract, + BaseContractMethod, + BigNumberish, + ContractTransactionResponse, +} from "ethers"; +import type { TransactionResponse } from "ethers/providers"; + +import { + assertHardhatInvariant, + HardhatError, +} from "@ignored/hardhat-vnext-errors"; +import { isObject } from "@ignored/hardhat-vnext-utils/lang"; +import { toBigInt } from "ethers/utils"; + +import { + CHANGE_TOKEN_BALANCES_MATCHER, + CHANGE_TOKEN_BALANCE_MATCHER, +} from "../constants.js"; +import { getAddressOf } from "../utils/account.js"; +import { assertIsNotNull } from "../utils/asserts.js"; +import { buildAssert } from "../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../utils/prevent-chaining.js"; + +export type Token = BaseContract & { + balanceOf: BaseContractMethod<[string], bigint, bigint>; + name: BaseContractMethod<[], string, string>; + transfer: BaseContractMethod< + [string, BigNumberish], + boolean, + ContractTransactionResponse + >; + symbol: BaseContractMethod<[], string, string>; +}; + +export function supportChangeTokenBalance( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + CHANGE_TOKEN_BALANCE_MATCHER, + function ( + this: any, + provider: EthereumProvider, + token: Token, + account: Addressable | string, + balanceChange: bigint | ((change: bigint) => boolean), + ) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + let subject = this._obj; + if (typeof subject === "function") { + subject = subject(); + } + + preventAsyncMatcherChaining( + this, + CHANGE_TOKEN_BALANCE_MATCHER, + chaiUtils, + ); + + checkToken(token, CHANGE_TOKEN_BALANCE_MATCHER); + + const checkBalanceChange = ([actualChange, address, tokenDescription]: [ + bigint, + string, + string, + ]) => { + const assert = buildAssert(negated, checkBalanceChange); + + if (typeof balanceChange === "function") { + assert( + balanceChange(actualChange), + `Expected the balance of ${tokenDescription} tokens for "${address}" to satisfy the predicate, but it didn't (token balance change: ${actualChange.toString()} wei)`, + `Expected the balance of ${tokenDescription} tokens for "${address}" to NOT satisfy the predicate, but it did (token balance change: ${actualChange.toString()} wei)`, + ); + } else { + assert( + actualChange === toBigInt(balanceChange), + `Expected the balance of ${tokenDescription} tokens for "${address}" to change by ${balanceChange.toString()}, but it changed by ${actualChange.toString()}`, + `Expected the balance of ${tokenDescription} tokens for "${address}" NOT to change by ${balanceChange.toString()}, but it did`, + ); + } + }; + + const derivedPromise = Promise.all([ + getBalanceChange(provider, subject, token, account), + getAddressOf(account), + getTokenDescription(token), + ]).then(checkBalanceChange); + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + + return this; + }, + ); + + Assertion.addMethod( + CHANGE_TOKEN_BALANCES_MATCHER, + function ( + this: any, + provider: EthereumProvider, + token: Token, + accounts: Array, + balanceChanges: bigint[] | ((changes: bigint[]) => boolean), + ) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + let subject = this._obj; + if (typeof subject === "function") { + subject = subject(); + } + + preventAsyncMatcherChaining( + this, + CHANGE_TOKEN_BALANCES_MATCHER, + chaiUtils, + ); + + validateInput(this._obj, token, accounts, balanceChanges); + + const balanceChangesPromise = Promise.all( + accounts.map((account) => + getBalanceChange(provider, subject, token, account), + ), + ); + const addressesPromise = Promise.all(accounts.map(getAddressOf)); + + const checkBalanceChanges = ([ + actualChanges, + addresses, + tokenDescription, + ]: [bigint[], string[], string]) => { + const assert = buildAssert(negated, checkBalanceChanges); + + if (typeof balanceChanges === "function") { + assert( + balanceChanges(actualChanges), + `Expected the balance changes of ${tokenDescription} to satisfy the predicate, but they didn't`, + `Expected the balance changes of ${tokenDescription} to NOT satisfy the predicate, but they did`, + ); + } else { + assert( + actualChanges.every( + (change, ind) => change === toBigInt(balanceChanges[ind]), + ), + `Expected the balances of ${tokenDescription} tokens for ${addresses.join( + ", ", + )} to change by ${balanceChanges.join( + ", ", + )}, respectively, but they changed by ${actualChanges.join(", ")}`, + `Expected the balances of ${tokenDescription} tokens for ${addresses.join( + ", ", + )} NOT to change by ${balanceChanges.join( + ", ", + )}, respectively, but they did`, + ); + } + }; + + const derivedPromise = Promise.all([ + balanceChangesPromise, + addressesPromise, + getTokenDescription(token), + ]).then(checkBalanceChanges); + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + + return this; + }, + ); +} + +function validateInput( + obj: any, + token: Token, + accounts: Array, + balanceChanges: bigint[] | ((changes: bigint[]) => boolean), +) { + try { + checkToken(token, CHANGE_TOKEN_BALANCES_MATCHER); + + if ( + Array.isArray(balanceChanges) && + accounts.length !== balanceChanges.length + ) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.ACCOUNTS_NUMBER_DIFFERENT_FROM_BALANCE_CHANGES, + { + accounts: accounts.length, + balanceChanges: balanceChanges.length, + }, + ); + } + } catch (e) { + // if the input validation fails, we discard the subject since it could + // potentially be a rejected promise + Promise.resolve(obj).catch(() => {}); + throw e; + } +} + +function checkToken(token: unknown, method: string) { + if (!isObject(token) || token === null || !("interface" in token)) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, + { + method, + }, + ); + } else if ( + isObject(token) && + "interface" in token && + isObject(token.interface) && + "getFunction" in token.interface && + typeof token.interface.getFunction === "function" && + token.interface.getFunction("balanceOf") === null + ) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.CONTRACT_IS_NOT_AN_ERC20_TOKEN, + ); + } +} + +export async function getBalanceChange( + provider: EthereumProvider, + transaction: TransactionResponse | Promise, + token: Token, + account: Addressable | string, +): Promise { + const txResponse = await transaction; + + const txReceipt = await txResponse.wait(); + assertIsNotNull(txReceipt, "txReceipt"); + const txBlockNumber = txReceipt.blockNumber; + + const block = await provider.request({ + method: "eth_getBlockByHash", + params: [txReceipt.blockHash, false], + }); + + assertHardhatInvariant( + isObject(block) && + Array.isArray(block.transactions) && + block.transactions.length === 1, + "There should be only 1 transaction in the block", + ); + + const address = await getAddressOf(account); + + const balanceAfter = await token.balanceOf(address, { + blockTag: txBlockNumber, + }); + + const balanceBefore = await token.balanceOf(address, { + blockTag: txBlockNumber - 1, + }); + + return toBigInt(balanceAfter) - balanceBefore; +} + +let tokenDescriptionsCache: Record = {}; +/** + * Get a description for the given token. Use the symbol of the token if + * possible; if it doesn't exist, the name is used; if the name doesn't + * exist, the address of the token is used. + */ +async function getTokenDescription(token: Token): Promise { + const tokenAddress = await token.getAddress(); + if (tokenDescriptionsCache[tokenAddress] === undefined) { + let tokenDescription = ``; + try { + tokenDescription = await token.symbol(); + } catch (e) { + try { + tokenDescription = await token.name(); + } catch (e2) {} + } + + tokenDescriptionsCache[tokenAddress] = tokenDescription; + } + + return tokenDescriptionsCache[tokenAddress]; +} + +// only used by tests +export function clearTokenDescriptionsCache(): void { + tokenDescriptionsCache = {}; +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/emit.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/emit.ts new file mode 100644 index 0000000000..5d7e026635 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/emit.ts @@ -0,0 +1,232 @@ +import type { AssertWithSsfi, Ssfi } from "../utils/ssfi.js"; +import type { EventFragment } from "ethers/abi"; +import type { Contract } from "ethers/contract"; +import type { Provider, TransactionReceipt } from "ethers/providers"; +import type { Transaction } from "ethers/transaction"; + +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { AssertionError } from "chai"; + +import { ASSERTION_ABORTED, EMIT_MATCHER } from "../constants.js"; +import { assertArgsArraysEqual, assertIsNotNull } from "../utils/asserts.js"; +import { buildAssert } from "../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../utils/prevent-chaining.js"; + +export const EMIT_CALLED = "emitAssertionCalled"; + +async function waitForPendingTransaction( + tx: Promise | Transaction | string, + provider: Provider, +) { + let hash: string | null; + if (tx instanceof Promise) { + ({ hash } = await tx); + } else if (typeof tx === "string") { + hash = tx; + } else { + ({ hash } = tx); + } + + if (hash === null) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.INVALID_TRANSACTION, + { transaction: JSON.stringify(tx) }, + ); + } + + return provider.getTransactionReceipt(hash); +} + +export function supportEmit( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + EMIT_MATCHER, + function ( + this: any, + contract: Contract, + eventName: string, + ...args: any[] + ) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + const tx = this._obj; + + preventAsyncMatcherChaining(this, EMIT_MATCHER, chaiUtils, true); + + const promise = this.then === undefined ? Promise.resolve() : this; + + const onSuccess = (receipt: TransactionReceipt) => { + // abort if the assertion chain was aborted, for example because + // a `.not` was combined with a `.withArgs` + if (chaiUtils.flag(this, ASSERTION_ABORTED) === true) { + return; + } + + const assert = buildAssert(negated, onSuccess); + + let eventFragment: EventFragment | null = null; + try { + eventFragment = contract.interface.getEvent(eventName, []); + } catch (e) { + if (e instanceof TypeError) { + const errorMessage = e.message.split(" (argument=")[0]; + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError(errorMessage); + } + } + + if (eventFragment === null) { + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError( + `Event "${eventName}" doesn't exist in the contract`, + ); + } + + const topic = eventFragment.topicHash; + const contractAddress = contract.target; + if (typeof contractAddress !== "string") { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.CONTRACT_TARGET_MUST_BE_A_STRING, + ); + } + + if (args.length > 0) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.EMIT_EXPECTS_TWO_ARGUMENTS, + ); + } + + this.logs = receipt.logs + .filter((log) => log.topics.includes(topic)) + .filter( + (log) => + log.address.toLowerCase() === contractAddress.toLowerCase(), + ); + + assert( + this.logs.length > 0, + `Expected event "${eventName}" to be emitted, but it wasn't`, + `Expected event "${eventName}" NOT to be emitted, but it was`, + ); + chaiUtils.flag(this, "eventName", eventName); + chaiUtils.flag(this, "contract", contract); + }; + + const derivedPromise = promise.then(() => { + // abort if the assertion chain was aborted, for example because + // a `.not` was combined with a `.withArgs` + if (chaiUtils.flag(this, ASSERTION_ABORTED) === true) { + return; + } + + if (contract.runner === null || contract.runner.provider === null) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.CONTRACT_RUNNER_PROVIDER_NOT_NULL, + ); + } + + return waitForPendingTransaction(tx, contract.runner.provider).then( + (receipt) => { + assertIsNotNull(receipt, "receipt"); + return onSuccess(receipt); + }, + ); + }); + + chaiUtils.flag(this, EMIT_CALLED, true); + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + this.promise = derivedPromise; + return this; + }, + ); +} + +export async function emitWithArgs( + context: any, + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, + expectedArgs: any[], + ssfi: Ssfi, +): Promise { + const negated = false; // .withArgs cannot be negated + const assert = buildAssert(negated, ssfi); + + tryAssertArgsArraysEqual( + context, + Assertion, + chaiUtils, + expectedArgs, + context.logs, + assert, + ssfi, + ); +} + +const tryAssertArgsArraysEqual = ( + context: any, + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, + expectedArgs: any[], + logs: any[], + assert: AssertWithSsfi, + ssfi: Ssfi, +) => { + const eventName = chaiUtils.flag(context, "eventName"); + + if (logs.length === 1) { + const parsedLog = chaiUtils + .flag(context, "contract") + .interface.parseLog(logs[0]); + assertIsNotNull(parsedLog, "parsedLog"); + + return assertArgsArraysEqual( + Assertion, + expectedArgs, + parsedLog.args, + `"${eventName}" event`, + "event", + assert, + ssfi, + ); + } + + for (const index in logs) { + if (index === undefined) { + break; + } else { + try { + const parsedLog = chaiUtils + .flag(context, "contract") + .interface.parseLog(logs[index]); + assertIsNotNull(parsedLog, "parsedLog"); + + assertArgsArraysEqual( + Assertion, + expectedArgs, + parsedLog.args, + `"${eventName}" event`, + "event", + assert, + ssfi, + ); + + return; + } catch {} + } + } + + assert( + false, + `The specified arguments (${util.inspect( + expectedArgs, + )}) were not included in any of the ${ + context.logs.length + } emitted "${eventName}" events`, + ); +}; diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/hexEqual.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/hexEqual.ts new file mode 100644 index 0000000000..0cd6774762 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/hexEqual.ts @@ -0,0 +1,29 @@ +export function supportHexEqual(Assertion: Chai.AssertionStatic): void { + Assertion.addMethod("hexEqual", function (this: any, other: string) { + const subject = this._obj; + const isNegated = this.__flags.negate === true; + + // check that both values are proper hex strings + const isHex = (a: string) => /^0x[0-9a-fA-F]*$/.test(a); + for (const element of [subject, other]) { + if (!isHex(element)) { + this.assert( + isNegated, // trick to make this assertion always fail + `Expected "${subject}" to be a hex string equal to "${other}", but "${element}" is not a valid hex string`, + `Expected "${subject}" not to be a hex string equal to "${other}", but "${element}" is not a valid hex string`, + ); + } + } + + // compare values + const extractNumeric = (hex: string) => hex.replace(/^0x0*/, ""); + this.assert( + extractNumeric(subject.toLowerCase()) === + extractNumeric(other.toLowerCase()), + `Expected "${subject}" to be a hex string equal to "${other}"`, + `Expected "${subject}" NOT to be a hex string equal to "${other}", but it was`, + `Hex string representing the same number as ${other}`, + subject, + ); + }); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/properAddress.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/properAddress.ts new file mode 100644 index 0000000000..9dc0cb4ccb --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/properAddress.ts @@ -0,0 +1,12 @@ +export function supportProperAddress(Assertion: Chai.AssertionStatic): void { + Assertion.addProperty("properAddress", function (this: any) { + const subject = this._obj; + this.assert( + /^0x[0-9a-fA-F]{40}$/.test(subject), + `Expected "${subject}" to be a proper address`, + `Expected "${subject}" NOT to be a proper address`, + "proper address (eg.: 0x1234567890123456789012345678901234567890)", + subject, + ); + }); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/properHex.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/properHex.ts new file mode 100644 index 0000000000..2be2ba895a --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/properHex.ts @@ -0,0 +1,29 @@ +export function supportProperHex(Assertion: Chai.AssertionStatic): void { + Assertion.addMethod("properHex", function (this: any, length: number) { + const subject = this._obj; + const isNegated = this.__flags.negate === true; + + const isHex = (a: string) => /^0x[0-9a-fA-F]*$/.test(a); + if (!isHex(subject)) { + this.assert( + isNegated, // trick to make this assertion always fail + `Expected "${subject}" to be a proper hex string, but it contains invalid (non-hex) characters`, + `Expected "${subject}" NOT to be a proper hex string, but it contains only valid hex characters`, + ); + } + + this.assert( + subject.length === length + 2, + `Expected "${subject}" to be a hex string of length ${ + length + 2 + } (the provided ${length} plus 2 more for the "0x" prefix), but its length is ${ + subject.length + }`, + `Expected "${subject}" NOT to be a hex string of length ${ + length + 2 + } (the provided ${length} plus 2 more for the "0x" prefix), but its length is ${ + subject.length + }`, + ); + }); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/properPrivateKey.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/properPrivateKey.ts new file mode 100644 index 0000000000..777db3a9b8 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/properPrivateKey.ts @@ -0,0 +1,12 @@ +export function supportProperPrivateKey(Assertion: Chai.AssertionStatic): void { + Assertion.addProperty("properPrivateKey", function (this: any) { + const subject = this._obj; + this.assert( + /^0x[0-9a-fA-F]{64}$/.test(subject), + `Expected "${subject}" to be a proper private key`, + `Expected "${subject}" NOT to be a proper private key`, + "proper private key (eg.: 0x1010101010101010101010101010101010101010101010101010101010101010)", + subject, + ); + }); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/panic.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/panic.ts new file mode 100644 index 0000000000..0c9b2038c8 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/panic.ts @@ -0,0 +1,36 @@ +export const PANIC_CODES = { + ASSERTION_ERROR: 0x1, + ARITHMETIC_OVERFLOW: 0x11, + DIVISION_BY_ZERO: 0x12, + ENUM_CONVERSION_OUT_OF_BOUNDS: 0x21, + INCORRECTLY_ENCODED_STORAGE_BYTE_ARRAY: 0x22, + POP_ON_EMPTY_ARRAY: 0x31, + ARRAY_ACCESS_OUT_OF_BOUNDS: 0x32, + TOO_MUCH_MEMORY_ALLOCATED: 0x41, + ZERO_INITIALIZED_VARIABLE: 0x51, +}; + +export function panicErrorCodeToReason(errorCode: bigint): string | undefined { + switch (errorCode) { + case 0x1n: + return "Assertion error"; + case 0x11n: + return "Arithmetic operation overflowed outside of an unchecked block"; + case 0x12n: + return "Division or modulo division by zero"; + case 0x21n: + return "Tried to convert a value into an enum, but the value was too big or negative"; + case 0x22n: + return "Incorrectly encoded storage byte array"; + case 0x31n: + return ".pop() was called on an empty array"; + case 0x32n: + return "Array accessed at an out-of-bounds or negative index"; + case 0x41n: + return "Too much memory was allocated, or an array was created that is too large"; + case 0x51n: + return "Called a zero-initialized variable of internal function type"; + default: + return undefined; + } +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/reverted.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/reverted.ts new file mode 100644 index 0000000000..07d1cd36fd --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/reverted.ts @@ -0,0 +1,165 @@ +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { numberToHexString } from "@ignored/hardhat-vnext-utils/hex"; + +import { REVERTED_MATCHER } from "../../constants.js"; +import { assertIsNotNull } from "../../utils/asserts.js"; +import { buildAssert } from "../../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../../utils/prevent-chaining.js"; + +import { + decodeReturnData, + getReturnDataFromError, + parseBytes32String, +} from "./utils.js"; + +export function supportReverted( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + REVERTED_MATCHER, + function (this: any, ethers: HardhatEthers) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + const subject: unknown = this._obj; + + preventAsyncMatcherChaining(this, REVERTED_MATCHER, chaiUtils); + + // Check if the received value can be linked to a transaction, and then + // get the receipt of that transaction and check its status. + // + // If the value doesn't correspond to a transaction, then the `reverted` + // assertion is false. + const onSuccess = async (value: unknown) => { + const assert = buildAssert(negated, onSuccess); + + if (isTransactionResponse(value) || typeof value === "string") { + const hash = typeof value === "string" ? value : value.hash; + + if (!isValidTransactionHash(hash)) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.EXPECTED_VALID_TRANSACTION_HASH, + { + hash, + }, + ); + } + + const receipt = await getTransactionReceipt(ethers, hash); + + if (receipt === null) { + // If the receipt is null, maybe the string is a bytes32 string + if (isBytes32String(hash)) { + assert(false, "Expected transaction to be reverted"); + return; + } + } + + assertIsNotNull(receipt, "receipt"); + assert( + receipt.status === 0, + "Expected transaction to be reverted", + "Expected transaction NOT to be reverted", + ); + } else if (isTransactionReceipt(value)) { + const receipt = value; + + assert( + receipt.status === 0, + "Expected transaction to be reverted", + "Expected transaction NOT to be reverted", + ); + } else { + // If the subject of the assertion is not connected to a transaction + // (hash, receipt, etc.), then the assertion fails. + // Since we use `false` here, this means that `.not.to.be.reverted` + // assertions will pass instead of always throwing a validation error. + // This allows users to do things like: + // `expect(c.callStatic.f()).to.not.be.reverted` + assert(false, "Expected transaction to be reverted"); + } + }; + + const onError = (error: any) => { + const assert = buildAssert(negated, onError); + const returnData = getReturnDataFromError(error); + const decodedReturnData = decodeReturnData(returnData); + + if ( + decodedReturnData.kind === "Empty" || + decodedReturnData.kind === "Custom" + ) { + // in the negated case, if we can't decode the reason, we just indicate + // that the transaction didn't revert + assert(true, undefined, `Expected transaction NOT to be reverted`); + } else if (decodedReturnData.kind === "Error") { + assert( + true, + undefined, + `Expected transaction NOT to be reverted, but it reverted with reason '${decodedReturnData.reason}'`, + ); + } else if (decodedReturnData.kind === "Panic") { + assert( + true, + undefined, + `Expected transaction NOT to be reverted, but it reverted with panic code ${numberToHexString( + decodedReturnData.code, + )} (${decodedReturnData.description})`, + ); + } else { + const _exhaustiveCheck: never = decodedReturnData; + } + }; + + // we use `Promise.resolve(subject)` so we can process both values and + // promises of values in the same way + const derivedPromise = Promise.resolve(subject).then(onSuccess, onError); + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + + return this; + }, + ); +} + +async function getTransactionReceipt(ethers: HardhatEthers, hash: string) { + return ethers.provider.getTransactionReceipt(hash); +} + +function isTransactionResponse(x: unknown): x is { hash: string } { + if (typeof x === "object" && x !== null) { + return "hash" in x; + } + + return false; +} + +function isTransactionReceipt(x: unknown): x is { status: number } { + if (typeof x === "object" && x !== null && "status" in x) { + const status = x.status; + + // this means we only support ethers's receipts for now; adding support for + // raw receipts, where the status is an hexadecimal string, should be easy + // and we can do it if there's demand for that + return typeof status === "number"; + } + + return false; +} + +function isValidTransactionHash(x: string): boolean { + return /0x[0-9a-fA-F]{64}/.test(x); +} + +function isBytes32String(v: string): boolean { + try { + parseBytes32String(v); + return true; + } catch { + return false; + } +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWith.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWith.ts new file mode 100644 index 0000000000..6191205df2 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWith.ts @@ -0,0 +1,100 @@ +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { numberToHexString } from "@ignored/hardhat-vnext-utils/hex"; + +import { REVERTED_WITH_MATCHER } from "../../constants.js"; +import { buildAssert } from "../../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../../utils/prevent-chaining.js"; + +import { decodeReturnData, getReturnDataFromError } from "./utils.js"; + +export function supportRevertedWith( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + REVERTED_WITH_MATCHER, + function (this: any, expectedReason: string | RegExp) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + // validate expected reason + if ( + !(expectedReason instanceof RegExp) && + typeof expectedReason !== "string" + ) { + // if the input validation fails, we discard the subject since it could + // potentially be a rejected promise + Promise.resolve(this._obj).catch(() => {}); + + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, + ); + } + + const expectedReasonString = + expectedReason instanceof RegExp + ? expectedReason.source + : expectedReason; + + preventAsyncMatcherChaining(this, REVERTED_WITH_MATCHER, chaiUtils); + + const onSuccess = () => { + const assert = buildAssert(negated, onSuccess); + + assert( + false, + `Expected transaction to be reverted with reason '${expectedReasonString}', but it didn't revert`, + ); + }; + + const onError = (error: any) => { + const assert = buildAssert(negated, onError); + + const returnData = getReturnDataFromError(error); + const decodedReturnData = decodeReturnData(returnData); + + if (decodedReturnData.kind === "Empty") { + assert( + false, + `Expected transaction to be reverted with reason '${expectedReasonString}', but it reverted without a reason`, + ); + } else if (decodedReturnData.kind === "Error") { + const matchesExpectedReason = + expectedReason instanceof RegExp + ? expectedReason.test(decodedReturnData.reason) + : decodedReturnData.reason === expectedReasonString; + + assert( + matchesExpectedReason, + `Expected transaction to be reverted with reason '${expectedReasonString}', but it reverted with reason '${decodedReturnData.reason}'`, + `Expected transaction NOT to be reverted with reason '${expectedReasonString}', but it was`, + ); + } else if (decodedReturnData.kind === "Panic") { + assert( + false, + `Expected transaction to be reverted with reason '${expectedReasonString}', but it reverted with panic code ${numberToHexString( + decodedReturnData.code, + )} (${decodedReturnData.description})`, + ); + } else if (decodedReturnData.kind === "Custom") { + assert( + false, + `Expected transaction to be reverted with reason '${expectedReasonString}', but it reverted with a custom error`, + ); + } else { + const _exhaustiveCheck: never = decodedReturnData; + } + }; + + const derivedPromise = Promise.resolve(this._obj).then( + onSuccess, + onError, + ); + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + + return this; + }, + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts new file mode 100644 index 0000000000..617c2c835b --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts @@ -0,0 +1,243 @@ +import type { Ssfi } from "../../utils/ssfi.js"; +import type { ErrorFragment, Interface } from "ethers/abi"; +import type { BaseContract } from "ethers/contract"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { numberToHexString } from "@ignored/hardhat-vnext-utils/hex"; + +import { + ASSERTION_ABORTED, + REVERTED_WITH_CUSTOM_ERROR_MATCHER, +} from "../../constants.js"; +import { assertArgsArraysEqual, assertIsNotNull } from "../../utils/asserts.js"; +import { buildAssert } from "../../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../../utils/prevent-chaining.js"; + +import { + decodeReturnData, + getReturnDataFromError, + resultToArray, +} from "./utils.js"; + +export const REVERTED_WITH_CUSTOM_ERROR_CALLED = "customErrorAssertionCalled"; + +interface CustomErrorAssertionData { + contractInterface: Interface; + returnData: string; + customError: ErrorFragment; +} + +export function supportRevertedWithCustomError( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + REVERTED_WITH_CUSTOM_ERROR_MATCHER, + function ( + this: any, + contract: BaseContract, + expectedCustomErrorName: string, + ...args: any[] + ) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + const { iface, expectedCustomError } = validateInput( + this._obj, + contract, + expectedCustomErrorName, + args, + ); + + preventAsyncMatcherChaining( + this, + REVERTED_WITH_CUSTOM_ERROR_MATCHER, + chaiUtils, + ); + + const onSuccess = () => { + if (chaiUtils.flag(this, ASSERTION_ABORTED) === true) { + return; + } + + const assert = buildAssert(negated, onSuccess); + + assert( + false, + `Expected transaction to be reverted with custom error '${expectedCustomErrorName}', but it didn't revert`, + ); + }; + + const onError = (error: any) => { + if (chaiUtils.flag(this, ASSERTION_ABORTED) === true) { + return; + } + + const assert = buildAssert(negated, onError); + + const returnData = getReturnDataFromError(error); + const decodedReturnData = decodeReturnData(returnData); + + if (decodedReturnData.kind === "Empty") { + assert( + false, + `Expected transaction to be reverted with custom error '${expectedCustomErrorName}', but it reverted without a reason`, + ); + } else if (decodedReturnData.kind === "Error") { + assert( + false, + `Expected transaction to be reverted with custom error '${expectedCustomErrorName}', but it reverted with reason '${decodedReturnData.reason}'`, + ); + } else if (decodedReturnData.kind === "Panic") { + assert( + false, + `Expected transaction to be reverted with custom error '${expectedCustomErrorName}', but it reverted with panic code ${numberToHexString( + decodedReturnData.code, + )} (${decodedReturnData.description})`, + ); + } else if (decodedReturnData.kind === "Custom") { + if (decodedReturnData.id === expectedCustomError.selector) { + // add flag with the data needed for .withArgs + const customErrorAssertionData: CustomErrorAssertionData = { + contractInterface: iface, + customError: expectedCustomError, + returnData, + }; + this.customErrorData = customErrorAssertionData; + + assert( + true, + undefined, + `Expected transaction NOT to be reverted with custom error '${expectedCustomErrorName}', but it was`, + ); + } else { + // try to decode the actual custom error + // this will only work when the error comes from the given contract + const actualCustomError = iface.getError(decodedReturnData.id); + + if (actualCustomError === null) { + assert( + false, + `Expected transaction to be reverted with custom error '${expectedCustomErrorName}', but it reverted with a different custom error`, + ); + } else { + assert( + false, + `Expected transaction to be reverted with custom error '${expectedCustomErrorName}', but it reverted with custom error '${actualCustomError.name}'`, + ); + } + } + } else { + const _exhaustiveCheck: never = decodedReturnData; + } + }; + + const derivedPromise = Promise.resolve(this._obj).then( + onSuccess, + onError, + ); + + // needed for .withArgs + chaiUtils.flag(this, REVERTED_WITH_CUSTOM_ERROR_CALLED, true); + this.promise = derivedPromise; + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + + return this; + }, + ); +} + +function validateInput( + obj: any, + contract: BaseContract, + expectedCustomErrorName: string, + args: any[], +): { iface: Interface; expectedCustomError: ErrorFragment } { + try { + // check the case where users forget to pass the contract as the first + // argument + if (typeof contract === "string" || contract?.interface === undefined) { + // discard subject since it could potentially be a rejected promise + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.FIRST_ARGUMENT_MUST_BE_A_CONTRACT, + ); + } + + // validate custom error name + if (typeof expectedCustomErrorName !== "string") { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.STRING_EXPECTED_AS_CUSTOM_ERROR_NAME, + ); + } + + const iface = contract.interface; + const expectedCustomError = iface.getError(expectedCustomErrorName); + + // check that interface contains the given custom error + if (expectedCustomError === null) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.CONTRACT_DOES_NOT_HAVE_CUSTOM_ERROR, + { + customErrorName: expectedCustomErrorName, + }, + ); + } + + if (args.length > 0) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.REVERT_INVALID_ARGUMENTS_LENGTH, + ); + } + + return { iface, expectedCustomError }; + } catch (e) { + // if the input validation fails, we discard the subject since it could + // potentially be a rejected promise + Promise.resolve(obj).catch(() => {}); + throw e; + } +} + +export async function revertedWithCustomErrorWithArgs( + context: any, + Assertion: Chai.AssertionStatic, + _chaiUtils: Chai.ChaiUtils, + expectedArgs: any[], + ssfi: Ssfi, +): Promise { + const negated = false; // .withArgs cannot be negated + const assert = buildAssert(negated, ssfi); + + const customErrorAssertionData: CustomErrorAssertionData = + context.customErrorData; + + if (customErrorAssertionData === undefined) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.WITH_ARGS_FORBIDDEN, + ); + } + + const { contractInterface, customError, returnData } = + customErrorAssertionData; + + const errorFragment = contractInterface.getError(customError.name); + + assertIsNotNull(errorFragment, "errorFragment"); + + // We transform ether's Array-like object into an actual array as it's safer + const actualArgs = resultToArray( + contractInterface.decodeErrorResult(errorFragment, returnData), + ); + + assertArgsArraysEqual( + Assertion, + expectedArgs, + actualArgs, + `"${customError.name}" custom error`, + "error", + assert, + ssfi, + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts new file mode 100644 index 0000000000..c61b84db84 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts @@ -0,0 +1,118 @@ +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { toBigInt } from "@ignored/hardhat-vnext-utils/bigint"; +import { numberToHexString } from "@ignored/hardhat-vnext-utils/hex"; + +import { REVERTED_WITH_PANIC_MATCHER } from "../../constants.js"; +import { buildAssert } from "../../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../../utils/prevent-chaining.js"; + +import { panicErrorCodeToReason } from "./panic.js"; +import { decodeReturnData, getReturnDataFromError } from "./utils.js"; + +export function supportRevertedWithPanic( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod( + REVERTED_WITH_PANIC_MATCHER, + function (this: any, expectedCodeArg: any) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + let expectedCode: bigint | undefined; + try { + if (expectedCodeArg !== undefined) { + expectedCode = toBigInt(expectedCodeArg); + } + } catch { + // if the input validation fails, we discard the subject since it could + // potentially be a rejected promise + Promise.resolve(this._obj).catch(() => {}); + + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.PANIC_CODE_EXPECTED, + { + panicCode: expectedCodeArg, + }, + ); + } + + const code: bigint | undefined = expectedCode; + + let description: string | undefined; + let formattedPanicCode: string; + if (code === undefined) { + formattedPanicCode = "some panic code"; + } else { + const codeBN = toBigInt(code); + description = panicErrorCodeToReason(codeBN) ?? "unknown panic code"; + formattedPanicCode = `panic code ${numberToHexString(codeBN)} (${description})`; + } + + preventAsyncMatcherChaining(this, REVERTED_WITH_PANIC_MATCHER, chaiUtils); + + const onSuccess = () => { + const assert = buildAssert(negated, onSuccess); + + assert( + false, + `Expected transaction to be reverted with ${formattedPanicCode}, but it didn't revert`, + ); + }; + + const onError = (error: any) => { + const assert = buildAssert(negated, onError); + + const returnData = getReturnDataFromError(error); + const decodedReturnData = decodeReturnData(returnData); + + if (decodedReturnData.kind === "Empty") { + assert( + false, + `Expected transaction to be reverted with ${formattedPanicCode}, but it reverted without a reason`, + ); + } else if (decodedReturnData.kind === "Error") { + assert( + false, + `Expected transaction to be reverted with ${formattedPanicCode}, but it reverted with reason '${decodedReturnData.reason}'`, + ); + } else if (decodedReturnData.kind === "Panic") { + if (code !== undefined) { + assert( + decodedReturnData.code === code, + `Expected transaction to be reverted with ${formattedPanicCode}, but it reverted with panic code ${numberToHexString( + decodedReturnData.code, + )} (${decodedReturnData.description})`, + `Expected transaction NOT to be reverted with ${formattedPanicCode}, but it was`, + ); + } else { + assert( + true, + undefined, + `Expected transaction NOT to be reverted with ${formattedPanicCode}, but it reverted with panic code ${numberToHexString( + decodedReturnData.code, + )} (${decodedReturnData.description})`, + ); + } + } else if (decodedReturnData.kind === "Custom") { + assert( + false, + `Expected transaction to be reverted with ${formattedPanicCode}, but it reverted with a custom error`, + ); + } else { + const _exhaustiveCheck: never = decodedReturnData; + } + }; + + const derivedPromise = Promise.resolve(this._obj).then( + onSuccess, + onError, + ); + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + + return this; + }, + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithoutReason.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithoutReason.ts new file mode 100644 index 0000000000..81f626ee33 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/revertedWithoutReason.ts @@ -0,0 +1,73 @@ +import { numberToHexString } from "@ignored/hardhat-vnext-utils/hex"; + +import { REVERTED_WITHOUT_REASON_MATCHER } from "../../constants.js"; +import { buildAssert } from "../../utils/build-assert.js"; +import { preventAsyncMatcherChaining } from "../../utils/prevent-chaining.js"; + +import { decodeReturnData, getReturnDataFromError } from "./utils.js"; + +export function supportRevertedWithoutReason( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod(REVERTED_WITHOUT_REASON_MATCHER, function (this: any) { + // capture negated flag before async code executes; see buildAssert's jsdoc + const negated = this.__flags.negate; + + preventAsyncMatcherChaining( + this, + REVERTED_WITHOUT_REASON_MATCHER, + chaiUtils, + ); + + const onSuccess = () => { + const assert = buildAssert(negated, onSuccess); + + assert( + false, + `Expected transaction to be reverted without a reason, but it didn't revert`, + ); + }; + + const onError = (error: any) => { + const assert = buildAssert(negated, onError); + + const returnData = getReturnDataFromError(error); + const decodedReturnData = decodeReturnData(returnData); + + if (decodedReturnData.kind === "Error") { + assert( + false, + `Expected transaction to be reverted without a reason, but it reverted with reason '${decodedReturnData.reason}'`, + ); + } else if (decodedReturnData.kind === "Empty") { + assert( + true, + undefined, + "Expected transaction NOT to be reverted without a reason, but it was", + ); + } else if (decodedReturnData.kind === "Panic") { + assert( + false, + `Expected transaction to be reverted without a reason, but it reverted with panic code ${numberToHexString( + decodedReturnData.code, + )} (${decodedReturnData.description})`, + ); + } else if (decodedReturnData.kind === "Custom") { + assert( + false, + `Expected transaction to be reverted without a reason, but it reverted with a custom error`, + ); + } else { + const _exhaustiveCheck: never = decodedReturnData; + } + }; + + const derivedPromise = Promise.resolve(this._obj).then(onSuccess, onError); + + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + + return this; + }); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/utils.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/utils.ts new file mode 100644 index 0000000000..6a71199e97 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/reverted/utils.ts @@ -0,0 +1,147 @@ +import type { Result } from "ethers/abi"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { ensureError } from "@ignored/hardhat-vnext-utils/error"; +import { AssertionError } from "chai"; +import { AbiCoder, decodeBytes32String } from "ethers/abi"; + +import { panicErrorCodeToReason } from "./panic.js"; + +// method id of 'Error(string)' +const ERROR_STRING_PREFIX = "0x08c379a0"; + +// method id of 'Panic(uint256)' +const PANIC_CODE_PREFIX = "0x4e487b71"; + +/** + * Try to obtain the return data of a transaction from the given value. + * + * If the value is an error but it doesn't have data, we assume it's not related + * to a reverted transaction and we re-throw it. + */ +export function getReturnDataFromError(error: any): string { + if (!(error instanceof Error)) { + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError("Expected an Error object"); + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- some properties do not exist in the default Error instance + const typedError = error as any; + + const errorData = typedError.data ?? typedError.error?.data; + + if (errorData === undefined) { + // eslint-disable-next-line no-restricted-syntax -- re-throw because the error is not related to a reverted transaction + throw error; + } + + const returnData = typeof errorData === "string" ? errorData : errorData.data; + + if (returnData === undefined || typeof returnData !== "string") { + // eslint-disable-next-line no-restricted-syntax -- re-throw because the error is not related to a reverted transaction + throw error; + } + + return returnData; +} + +type DecodedReturnData = + | { + kind: "Error"; + reason: string; + } + | { + kind: "Empty"; + } + | { + kind: "Panic"; + code: bigint; + description: string; + } + | { + kind: "Custom"; + id: string; + data: string; + }; + +export function decodeReturnData(returnData: string): DecodedReturnData { + const abi = new AbiCoder(); + + if (returnData === "0x") { + return { kind: "Empty" }; + } else if (returnData.startsWith(ERROR_STRING_PREFIX)) { + const encodedReason = returnData.slice(ERROR_STRING_PREFIX.length); + let reason: string; + + try { + reason = abi.decode(["string"], `0x${encodedReason}`)[0]; + } catch (e) { + ensureError(e); + + throw new HardhatError(HardhatError.ERRORS.CHAI_MATCHERS.DECODING_ERROR, { + encodedData: encodedReason, + type: "string", + reason: e.message, + }); + } + + return { + kind: "Error", + reason, + }; + } else if (returnData.startsWith(PANIC_CODE_PREFIX)) { + const encodedReason = returnData.slice(PANIC_CODE_PREFIX.length); + let code: bigint; + try { + code = abi.decode(["uint256"], `0x${encodedReason}`)[0]; + } catch (e) { + ensureError(e); + + throw new HardhatError(HardhatError.ERRORS.CHAI_MATCHERS.DECODING_ERROR, { + encodedData: encodedReason, + type: "uint256", + reason: e.message, + }); + } + + const description = panicErrorCodeToReason(code) ?? "unknown panic code"; + + return { + kind: "Panic", + code, + description, + }; + } + + return { + kind: "Custom", + id: returnData.slice(0, 10), + data: `0x${returnData.slice(10)}`, + }; +} + +/** + * Takes an ethers result object and converts it into a (potentially nested) array. + * + * For example, given this error: + * + * struct Point(uint x, uint y) + * error MyError(string, Point) + * + * revert MyError("foo", Point(1, 2)) + * + * The resulting array will be: ["foo", [1n, 2n]] + */ +export function resultToArray(result: Result): any[] { + return result + .toArray() + .map((x) => + typeof x === "object" && x !== null && "toArray" in x + ? resultToArray(x) + : x, + ); +} + +export function parseBytes32String(v: string): string { + return decodeBytes32String(v); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/matchers/withArgs.ts b/v-next/hardhat-chai-matchers/src/internal/matchers/withArgs.ts new file mode 100644 index 0000000000..6db6ac820e --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/matchers/withArgs.ts @@ -0,0 +1,139 @@ +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { toBigInt } from "@ignored/hardhat-vnext-utils/bigint"; +import { AssertionError } from "chai"; +import { isAddressable } from "ethers/address"; + +import { ASSERTION_ABORTED } from "../constants.js"; +import { isBigInt } from "../utils/bigint.js"; + +import { emitWithArgs, EMIT_CALLED } from "./emit.js"; +import { + revertedWithCustomErrorWithArgs, + REVERTED_WITH_CUSTOM_ERROR_CALLED, +} from "./reverted/revertedWithCustomError.js"; + +/** + * A predicate for use with .withArgs(...), to induce chai to accept any value + * as a positive match with the argument. + * + * Example: expect(contract.emitInt()).to.emit(contract, "Int").withArgs(anyValue) + */ +export function anyValue(): boolean { + return true; +} + +/** + * A predicate for use with .withArgs(...), to induce chai to accept any + * unsigned integer as a positive match with the argument. + * + * Example: expect(contract.emitUint()).to.emit(contract, "Uint").withArgs(anyUint) + */ +export function anyUint(i: any): boolean { + if (typeof i === "number") { + if (i < 0) { + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError( + `anyUint expected its argument to be an unsigned integer, but it was negative, with value ${i}`, + ); + } + + return true; + } else if (isBigInt(i)) { + const bigInt = toBigInt(i); + + if (bigInt < 0) { + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError( + `anyUint expected its argument to be an unsigned integer, but it was negative, with value ${bigInt}`, + ); + } + return true; + } + + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError( + `anyUint expected its argument to be an integer, but its type was '${typeof i}'`, + ); +} + +export function supportWithArgs( + Assertion: Chai.AssertionStatic, + chaiUtils: Chai.ChaiUtils, +): void { + Assertion.addMethod("withArgs", function (this: any, ...expectedArgs: any[]) { + const { emitCalled } = validateInput.call(this, chaiUtils); + + // Resolve arguments to their canonical form: + // - Addressable → address + const resolveArgument = (arg: any) => + isAddressable(arg) ? arg.getAddress() : arg; + + const onSuccess = (resolvedExpectedArgs: any[]) => { + if (emitCalled) { + return emitWithArgs( + this, + Assertion, + chaiUtils, + resolvedExpectedArgs, + onSuccess, + ); + } else { + return revertedWithCustomErrorWithArgs( + this, + Assertion, + chaiUtils, + resolvedExpectedArgs, + onSuccess, + ); + } + }; + + const promise = (this.then === undefined ? Promise.resolve() : this) + .then(() => Promise.all(expectedArgs.map(resolveArgument))) + .then(onSuccess); + + this.then = promise.then.bind(promise); + this.catch = promise.catch.bind(promise); + return this; + }); +} + +function validateInput( + this: any, + chaiUtils: Chai.ChaiUtils, +): { emitCalled: boolean } { + try { + if (Boolean(this.__flags.negate)) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.WITH_ARGS_CANNOT_BE_COMBINED_WITH_NOT, + ); + } + + const emitCalled = chaiUtils.flag(this, EMIT_CALLED) === true; + + const revertedWithCustomErrorCalled = + chaiUtils.flag(this, REVERTED_WITH_CUSTOM_ERROR_CALLED) === true; + + if (!emitCalled && !revertedWithCustomErrorCalled) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.WITH_ARGS_WRONG_COMBINATION, + ); + } + + if (emitCalled && revertedWithCustomErrorCalled) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.WITH_ARGS_COMBINED_WITH_INCOMPATIBLE_ASSERTIONS, + ); + } + + return { emitCalled }; + } catch (e) { + // signal that validation failed to allow the matchers to finish early + chaiUtils.flag(this, ASSERTION_ABORTED, true); + + // discard subject since it could potentially be a rejected promise + Promise.resolve(this._obj).catch(() => {}); + + throw e; + } +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/account.ts b/v-next/hardhat-chai-matchers/src/internal/utils/account.ts new file mode 100644 index 0000000000..93b1d83a5a --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/account.ts @@ -0,0 +1,24 @@ +import type { Addressable } from "ethers/address"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { isAddress } from "@ignored/hardhat-vnext-utils/eth"; +import { isAddressable } from "ethers/address"; + +export async function getAddressOf( + account: Addressable | string, +): Promise { + if (isAddress(account)) { + return account; + } + + if (isAddressable(account)) { + return account.getAddress(); + } + + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.EXPECTED_STRING_OR_ADDRESSABLE, + { + account, + }, + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/asserts.ts b/v-next/hardhat-chai-matchers/src/internal/utils/asserts.ts new file mode 100644 index 0000000000..a4d3a0a0a4 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/asserts.ts @@ -0,0 +1,156 @@ +import type { AssertWithSsfi, Ssfi } from "./ssfi.js"; + +import { + assertHardhatInvariant, + HardhatError, +} from "@ignored/hardhat-vnext-errors"; +import { ensureError } from "@ignored/hardhat-vnext-utils/error"; +import { keccak256 } from "ethers/crypto"; +import { getBytes, hexlify, isHexString, toUtf8Bytes } from "ethers/utils"; + +import { ordinal } from "./ordinal.js"; + +export function assertIsNotNull( + value: T, + valueName: string, +): asserts value is Exclude { + assertHardhatInvariant(value !== null, `${valueName} should not be null`); +} + +export function assertArgsArraysEqual( + Assertion: Chai.AssertionStatic, + expectedArgs: any[], + actualArgs: any[], + tag: string, + assertionType: "event" | "error", + assert: AssertWithSsfi, + ssfi: Ssfi, +): void { + try { + innerAssertArgsArraysEqual( + Assertion, + expectedArgs, + actualArgs, + assertionType, + assert, + ssfi, + ); + } catch (err) { + ensureError(err); + err.message = `Error in ${tag}: ${err.message}`; + throw err; + } +} + +function innerAssertArgsArraysEqual( + Assertion: Chai.AssertionStatic, + expectedArgs: any[], + actualArgs: any[], + assertionType: "event" | "error", + assert: AssertWithSsfi, + ssfi: Ssfi, +) { + assert( + actualArgs.length === expectedArgs.length, + `Expected arguments array to have length ${expectedArgs.length}, but it has ${actualArgs.length}`, + ); + for (const [index, expectedArg] of expectedArgs.entries()) { + try { + innerAssertArgEqual( + Assertion, + expectedArg, + actualArgs[index], + assertionType, + assert, + ssfi, + ); + } catch (err) { + ensureError(err); + err.message = `Error in the ${ordinal(index + 1)} argument assertion: ${ + err.message + }`; + throw err; + } + } +} + +function innerAssertArgEqual( + Assertion: Chai.AssertionStatic, + expectedArg: any, + actualArg: any, + assertionType: "event" | "error", + assert: AssertWithSsfi, + ssfi: Ssfi, +) { + if (typeof expectedArg === "function") { + try { + if (expectedArg(actualArg) === true) return; + } catch (e) { + ensureError(e); + + assert( + false, + `The predicate threw when called: ${e.message}`, + // no need for a negated message, since we disallow mixing .not. with + // .withArgs + ); + } + assert( + false, + `The predicate did not return true`, + // no need for a negated message, since we disallow mixing .not. with + // .withArgs + ); + } else if (expectedArg instanceof Uint8Array) { + new Assertion(actualArg, undefined, ssfi, true).equal(hexlify(expectedArg)); + } else if ( + expectedArg?.length !== undefined && + typeof expectedArg !== "string" + ) { + innerAssertArgsArraysEqual( + Assertion, + expectedArg, + actualArg, + assertionType, + assert, + ssfi, + ); + } else { + if (actualArg.hash !== undefined && actualArg._isIndexed === true) { + if (assertionType !== "event") { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.INDEXED_EVENT_FORBIDDEN, + ); + } + + new Assertion(actualArg.hash, undefined, ssfi, true).to.not.equal( + expectedArg, + "The actual value was an indexed and hashed value of the event argument. The expected value provided to the assertion should be the actual event argument (the pre-image of the hash). You provided the hash itself. Please supply the actual event argument (the pre-image of the hash) instead.", + ); + + const expectedArgBytes = isHexString(expectedArg) + ? getBytes(expectedArg) + : toUtf8Bytes(expectedArg); + + const expectedHash = keccak256(expectedArgBytes); + + new Assertion(actualArg.hash, undefined, ssfi, true).to.equal( + expectedHash, + `The actual value was an indexed and hashed value of the event argument. The expected value provided to the assertion was hashed to produce ${expectedHash}. The actual hash and the expected hash ${actualArg.hash} did not match`, + ); + } else { + new Assertion(actualArg, undefined, ssfi, true).equal(expectedArg); + } + } +} + +export function assertCanBeConvertedToBigint( + value: unknown, +): asserts value is string | number | bigint { + assertHardhatInvariant( + typeof value === "string" || + typeof value === "number" || + typeof value === "bigint", + "value should be of type string, number or bigint", + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/balance.ts b/v-next/hardhat-chai-matchers/src/internal/utils/balance.ts new file mode 100644 index 0000000000..0c7283ffa8 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/balance.ts @@ -0,0 +1,39 @@ +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { Addressable } from "ethers"; + +import { toBigInt } from "@ignored/hardhat-vnext-utils/bigint"; +import { numberToHexString } from "@ignored/hardhat-vnext-utils/hex"; + +import { getAddressOf } from "./account.js"; +import { assertCanBeConvertedToBigint } from "./asserts.js"; + +export interface BalanceChangeOptions { + includeFee?: boolean; +} + +export function getAddresses( + accounts: Array, +): Promise { + return Promise.all(accounts.map((account) => getAddressOf(account))); +} + +export async function getBalances( + provider: EthereumProvider, + accounts: Array, + blockNumber?: number, +): Promise { + return Promise.all( + accounts.map(async (account) => { + const address = await getAddressOf(account); + + const result = await provider.request({ + method: "eth_getBalance", + params: [address, numberToHexString(blockNumber ?? 0)], + }); + + assertCanBeConvertedToBigint(result); + + return toBigInt(result); + }), + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/bigint.ts b/v-next/hardhat-chai-matchers/src/internal/utils/bigint.ts new file mode 100644 index 0000000000..2792bba765 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/bigint.ts @@ -0,0 +1,3 @@ +export function isBigInt(source: any): boolean { + return typeof source === "bigint"; +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/build-assert.ts b/v-next/hardhat-chai-matchers/src/internal/utils/build-assert.ts new file mode 100644 index 0000000000..761becc11e --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/build-assert.ts @@ -0,0 +1,54 @@ +import type { Ssfi } from "./ssfi.js"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { AssertionError } from "chai"; + +/** + * This function is used by the matchers to obtain an `assert` function, which + * should be used instead of `this.assert`. + * + * The first parameter is the value of the `negated` flag. Keep in mind that + * this value should be captured at the beginning of the matcher's + * implementation, before any async code is executed. Otherwise things like + * `.to.emit().and.not.to.emit()` won't work, because by the time the async part + * of the first emit is executed, the `.not` (executed synchronously) has already + * modified the flag. + * + * The second parameter is what Chai calls the "start stack function indicator", + * a function that is used to build the stack trace. It's unclear to us what's + * the best way to use this value, so this needs some trial-and-error. Use the + * existing matchers for a reference of something that works well enough. + */ +export function buildAssert(negated: boolean, ssfi: Ssfi) { + return function ( + condition: boolean, + messageFalse?: string | (() => string), + messageTrue?: string | (() => string), + ): void { + if (!negated && !condition) { + if (messageFalse === undefined) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.ASSERTION_WITHOUT_ERROR_MESSAGE, + ); + } + + const message = + typeof messageFalse === "function" ? messageFalse() : messageFalse; + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError(message, undefined, ssfi); + } + + if (negated && condition) { + if (messageTrue === undefined) { + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.ASSERTION_WITHOUT_ERROR_MESSAGE, + ); + } + + const message = + typeof messageTrue === "function" ? messageTrue() : messageTrue; + // eslint-disable-next-line no-restricted-syntax -- keep the original chai error structure + throw new AssertionError(message, undefined, ssfi); + } + }; +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/ordinal.ts b/v-next/hardhat-chai-matchers/src/internal/utils/ordinal.ts new file mode 100644 index 0000000000..559642b518 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/ordinal.ts @@ -0,0 +1,24 @@ +/** + * Appends the appropriate ordinal suffix ("st", "nd", "rd", "th") to a given number. + * + * This function correctly handles special cases such as numbers ending in 11, 12, and 13, + * which always use the "th" suffix, and ensures the correct suffix for all other numbers. + */ +export function ordinal(n: number): string { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + + const suffixIndex = (v - 20) % 10; + let suffix = s[suffixIndex]; + + if (suffixIndex >= 1 && suffixIndex <= 3) { + return n + suffix; + } + + suffix = s[v]; + if (v >= 1 && v <= 3) { + return n + suffix; + } + + return n + s[0]; +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/prevent-chaining.ts b/v-next/hardhat-chai-matchers/src/internal/utils/prevent-chaining.ts new file mode 100644 index 0000000000..e8df98b871 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/prevent-chaining.ts @@ -0,0 +1,33 @@ +import { HardhatError } from "@ignored/hardhat-vnext-errors"; + +import { PREVIOUS_MATCHER_NAME } from "../constants.js"; + +export function preventAsyncMatcherChaining( + context: object, + matcherName: string, + chaiUtils: Chai.ChaiUtils, + allowSelfChaining: boolean = false, +): void { + const previousMatcherName: string | undefined = chaiUtils.flag( + context, + PREVIOUS_MATCHER_NAME, + ); + + if (previousMatcherName === undefined) { + chaiUtils.flag(context, PREVIOUS_MATCHER_NAME, matcherName); + + return; + } + + if (previousMatcherName === matcherName && allowSelfChaining) { + return; + } + + throw new HardhatError( + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: matcherName, + previousMatcher: previousMatcherName, + }, + ); +} diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/ssfi.ts b/v-next/hardhat-chai-matchers/src/internal/utils/ssfi.ts new file mode 100644 index 0000000000..b6b978af8b --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/ssfi.ts @@ -0,0 +1,6 @@ +import type { buildAssert } from "./build-assert.js"; + +// just a generic function type to avoid errors from the ban-types eslint rule +export type Ssfi = (...args: any[]) => any; + +export type AssertWithSsfi = ReturnType; diff --git a/v-next/hardhat-chai-matchers/src/internal/utils/typed.ts b/v-next/hardhat-chai-matchers/src/internal/utils/typed.ts new file mode 100644 index 0000000000..a8923d4cf8 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/internal/utils/typed.ts @@ -0,0 +1,9 @@ +import { Typed } from "ethers"; + +export function tryDereference(value: any, type: string): any | undefined { + try { + return Typed.dereference(value, type); + } catch { + return undefined; + } +} diff --git a/v-next/hardhat-chai-matchers/src/panic.ts b/v-next/hardhat-chai-matchers/src/panic.ts new file mode 100644 index 0000000000..f750be7bf8 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/panic.ts @@ -0,0 +1 @@ +export { PANIC_CODES } from "./internal/matchers/reverted/panic.js"; diff --git a/v-next/hardhat-chai-matchers/src/type-extensions.ts b/v-next/hardhat-chai-matchers/src/type-extensions.ts new file mode 100644 index 0000000000..a6a7837e74 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/type-extensions.ts @@ -0,0 +1,82 @@ +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +// We use declare global instead of declare module "chai", because that's what +// @types/chai does. +declare global { + /* eslint-disable-next-line @typescript-eslint/no-namespace -- We have to use + a namespace because @types/chai uses it. */ + namespace Chai { + interface Assertion + extends LanguageChains, + NumericComparison, + TypeComparison { + emit(contract: any, eventName: string): EmitAssertion; + reverted(ethers: HardhatEthers): AsyncAssertion; + revertedWith(reason: string | RegExp): AsyncAssertion; + revertedWithoutReason(ethers: HardhatEthers): AsyncAssertion; + revertedWithPanic(code?: any): AsyncAssertion; + revertedWithCustomError( + contract: { interface: any }, + customErrorName: string, + ): CustomErrorAssertion; + hexEqual(other: string): void; + properPrivateKey: void; + properAddress: void; + properHex(length: number): void; + changeEtherBalance( + provider: EthereumProvider, + account: any, + balance: any, + options?: any, + ): AsyncAssertion; + changeEtherBalances( + provider: EthereumProvider, + accounts: any[], + balances: any[] | ((changes: bigint[]) => boolean), + options?: any, + ): AsyncAssertion; + changeTokenBalance( + provider: EthereumProvider, + token: any, + account: any, + balance: any, + ): AsyncAssertion; + changeTokenBalances( + provider: EthereumProvider, + token: any, + account: any[], + balance: any[] | ((changes: bigint[]) => boolean), + ): AsyncAssertion; + } + + interface NumericComparison { + within(start: any, finish: any, message?: string): Assertion; + } + + interface NumberComparer { + // eslint-disable-next-line -- the interface must follow the original definition pattern + (value: any, message?: string): Assertion; + } + + interface CloseTo { + // eslint-disable-next-line -- the interface must follow the original definition pattern + (expected: any, delta: any, message?: string): Assertion; + } + + interface Length extends Assertion { + // eslint-disable-next-line -- the interface must follow the original definition pattern + (length: any, message?: string): Assertion; + } + + interface AsyncAssertion extends Assertion, Promise {} + + interface EmitAssertion extends AsyncAssertion { + withArgs(...args: any[]): AsyncAssertion; + } + + interface CustomErrorAssertion extends AsyncAssertion { + withArgs(...args: any[]): AsyncAssertion; + } + } +} diff --git a/v-next/hardhat-chai-matchers/src/withArgs.ts b/v-next/hardhat-chai-matchers/src/withArgs.ts new file mode 100644 index 0000000000..30aefb5c28 --- /dev/null +++ b/v-next/hardhat-chai-matchers/src/withArgs.ts @@ -0,0 +1 @@ +export { anyUint, anyValue } from "./internal/matchers/withArgs.js"; diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/.gitignore b/v-next/hardhat-chai-matchers/test/fixture-projects/.gitignore new file mode 100644 index 0000000000..e7f801166c --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/.gitignore @@ -0,0 +1,2 @@ +artifacts/ +cache/ diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/ChangeEtherBalance.sol b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/ChangeEtherBalance.sol new file mode 100644 index 0000000000..2bb109299a --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/ChangeEtherBalance.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +contract ChangeEtherBalance { + function returnHalf() public payable { + payable(msg.sender).transfer(msg.value / 2); + } + + function transferTo(address addr) public payable { + payable(addr).transfer(msg.value); + } + + receive() external payable {} +} diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Events.sol b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Events.sol new file mode 100644 index 0000000000..81d9941b43 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Events.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +contract Events { + AnotherContract anotherContract; + + struct Struct { + uint u; + uint v; + } + + event WithoutArgs(); + event WithUintArg(uint u); + event WithIntArg(int i); + event WithAddressArg(address a); + event WithTwoUintArgs(uint u, uint v); + event WithStringArg(string s); + event WithTwoStringArgs(string s, string t); + event WithIndexedStringArg(string indexed s); + event WithBytesArg(bytes b); + event WithIndexedBytesArg(bytes indexed b); + event WithBytes32Arg(bytes32 b); + event WithStructArg(Struct s); + event WithIndexedBytes32Arg(bytes32 indexed b); + event WithUintArray(uint[2] a); + event WithBytes32Array(bytes32[2] a); + + constructor(AnotherContract c) { + anotherContract = c; + } + + function doNotEmit() public {} + + function emitWithoutArgs() public { + emit WithoutArgs(); + } + + function emitUint(uint u) public { + emit WithUintArg(u); + } + + function emitInt(int i) public { + emit WithIntArg(i); + } + + function emitAddress(address a) public { + emit WithAddressArg(a); + } + + function emitUintTwice(uint u, uint v) public { + emit WithUintArg(u); + emit WithUintArg(v); + } + + function emitTwoUints(uint u, uint v) public { + emit WithTwoUintArgs(u, v); + } + + function emitString(string memory s) public { + emit WithStringArg(s); + } + + function emitIndexedString(string memory s) public { + emit WithIndexedStringArg(s); + } + + function emitBytes(bytes memory b) public { + emit WithBytesArg(b); + } + + function emitIndexedBytes(bytes memory b) public { + emit WithIndexedBytesArg(b); + } + + function emitBytes32(bytes32 b) public { + emit WithBytes32Arg(b); + } + + function emitIndexedBytes32(bytes32 b) public { + emit WithIndexedBytes32Arg(b); + } + + function emitUintAndString(uint u, string memory s) public { + emit WithStringArg(s); + emit WithUintArg(u); + } + + function emitTwoUintsAndTwoStrings( + uint u, + uint v, + string memory s, + string memory t + ) public { + emit WithTwoUintArgs(u, v); + emit WithTwoStringArgs(s, t); + } + + function emitStruct(uint u, uint v) public { + emit WithStructArg(Struct(u, v)); + } + + function emitUintArray(uint u, uint v) public { + emit WithUintArray([u, v]); + } + + function emitBytes32Array(bytes32 b, bytes32 c) public { + emit WithBytes32Array([b, c]); + } + + function emitNestedUintFromSameContract(uint u) public { + emitUint(u); + } + + function emitNestedUintFromAnotherContract(uint u) public { + anotherContract.emitUint(u); + } +} + +contract AnotherContract { + event WithUintArg(uint u); + + function emitUint(uint u) public { + emit WithUintArg(u); + } +} + +contract OverrideEventContract { + event simpleEvent(uint u); + event simpleEvent(); + + function emitSimpleEventWithUintArg(uint u) public { + emit simpleEvent(u); + } + + function emitSimpleEventWithoutArg() public { + emit simpleEvent(); + } +} diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Matchers.sol b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Matchers.sol new file mode 100644 index 0000000000..489bb1428b --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Matchers.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity ^0.8.24; + +contract Matchers { + uint x; + + event SomeEvent(); + + AnotherMatchersContract anotherContract; + + struct Pair { + uint a; + uint b; + } + + error SomeCustomError(); + error AnotherCustomError(); + error CustomErrorWithInt(int); + error CustomErrorWithUint( + uint nameToForceEthersToUseAnArrayLikeWithNamedProperties + ); + error CustomErrorWithUintAndString(uint, string); + error CustomErrorWithPair(Pair); + + constructor() { + anotherContract = new AnotherMatchersContract(); + } + + function succeeds() public { + x++; // just to avoid compiler warnings + } + + function succeedsView() public view returns (uint) { + return x; + } + + function revertsWith(string memory reason) public { + x++; + require(false, reason); + } + + function revertsWithView(string memory reason) public pure { + require(false, reason); + } + + function revertsWithoutReason() public { + x++; + require(false); + } + + function revertsWithoutReasonView() public pure { + require(false); + } + + function panicAssert() public { + x++; + assert(false); + } + + function panicAssertView() public pure { + assert(false); + } + + function revertWithSomeCustomError() public { + x++; + revert SomeCustomError(); + } + + function revertWithSomeCustomErrorView() public pure { + revert SomeCustomError(); + } + + function revertWithAnotherCustomError() public { + x++; + revert AnotherCustomError(); + } + + function revertWithAnotherCustomErrorView() public pure { + revert AnotherCustomError(); + } + + function revertWithAnotherContractCustomError() public { + x++; + anotherContract.revertWithYetAnotherCustomError(); + } + + function revertWithAnotherContractCustomErrorView() public view { + anotherContract.revertWithYetAnotherCustomErrorView(); + } + + function revertWithCustomErrorWithUint(uint n) public { + x++; + revert CustomErrorWithUint(n); + } + + function revertWithCustomErrorWithUintView(uint n) public pure { + revert CustomErrorWithUint(n); + } + + function revertWithCustomErrorWithInt(int i) public { + x++; + revert CustomErrorWithInt(i); + } + + function revertWithCustomErrorWithIntView(int i) public pure { + revert CustomErrorWithInt(i); + } + + function revertWithCustomErrorWithUintAndString( + uint n, + string memory s + ) public { + x++; + revert CustomErrorWithUintAndString(n, s); + } + + function revertWithCustomErrorWithUintAndStringView( + uint n, + string memory s + ) public pure { + revert CustomErrorWithUintAndString(n, s); + } + + function revertWithCustomErrorWithPair(uint a, uint b) public { + x++; + revert CustomErrorWithPair(Pair(a, b)); + } + + function revertWithCustomErrorWithPairView(uint a, uint b) public pure { + revert CustomErrorWithPair(Pair(a, b)); + } +} + +contract AnotherMatchersContract { + uint x; + + error YetAnotherCustomError(); + + function revertWithYetAnotherCustomError() public { + x++; + revert YetAnotherCustomError(); + } + + function revertWithYetAnotherCustomErrorView() public pure { + revert YetAnotherCustomError(); + } +} diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Token.sol b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Token.sol new file mode 100644 index 0000000000..75c0eb8c69 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/contracts/Token.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +contract TokenWithoutNameNorSymbol { + uint public decimals = 1; + + uint public totalSupply; + mapping(address => uint) public balanceOf; + mapping(address => mapping(address => uint)) allowances; + + constructor() { + totalSupply = 1_000_000_000; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint value) public returns (bool) { + require(value > 0, "Transferred value is zero"); + + balanceOf[msg.sender] -= value; + balanceOf[to] += value; + + return true; + } + + function allowance( + address owner, + address spender + ) public view returns (uint256 remaining) { + return allowances[owner][spender]; + } + + function approve( + address spender, + uint256 value + ) public returns (bool success) { + allowances[msg.sender][spender] = value; + return true; + } + + function transferFrom( + address from, + address to, + uint256 value + ) public returns (bool) { + require(allowance(from, msg.sender) >= value, "Insufficient allowance"); + + allowances[from][msg.sender] -= value; + balanceOf[from] -= value; + balanceOf[to] += value; + + return true; + } +} + +contract TokenWithOnlyName is TokenWithoutNameNorSymbol { + string public name = "MockToken"; +} + +contract MockToken is TokenWithoutNameNorSymbol { + string public name = "MockToken"; + string public symbol = "MCK"; +} + +contract NotAToken {} diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/hardhat.config.ts b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/hardhat.config.ts new file mode 100644 index 0000000000..5d8fb73ffa --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/hardhat.config.ts @@ -0,0 +1,9 @@ +import type { HardhatUserConfig } from "@ignored/hardhat-vnext/config"; + +import hardhatEthersPlugin from "@ignored/hardhat-vnext-ethers"; + +const config: HardhatUserConfig = { + plugins: [hardhatEthersPlugin], +}; + +export default config; diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/package.json b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/package.json new file mode 100644 index 0000000000..0746d6034c --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hardhat-project/package.json @@ -0,0 +1,9 @@ +{ + "name": "hardhat-project", + "private": "true", + "type": "module", + "dependencies": { + "@ignored/hardhat-vnext": "*", + "@ignored/hardhat-vnext-ethers": "*" + } +} diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/hardhat.config.ts b/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/hardhat.config.ts new file mode 100644 index 0000000000..ca9509d04f --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/hardhat.config.ts @@ -0,0 +1,12 @@ +import type { HardhatUserConfig } from "@ignored/hardhat-vnext/config"; + +import HardhatMochaPlugin from "@ignored/hardhat-vnext-mocha-test-runner"; + +// eslint-disable-next-line import/no-relative-packages -- allow in fixture projects +import hardhatChaiMatchersPlugin from "../../../src/index.js"; + +const config: HardhatUserConfig = { + plugins: [HardhatMochaPlugin, hardhatChaiMatchersPlugin], +}; + +export default config; diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/package.json b/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/package.json new file mode 100644 index 0000000000..163ad78225 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/package.json @@ -0,0 +1,8 @@ +{ + "name": "hardhat-project", + "private": "true", + "type": "module", + "dependencies": { + "@ignored/hardhat-vnext-mocha-test-runner": "*" + } +} diff --git a/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/test/test.ts b/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/test/test.ts new file mode 100644 index 0000000000..74681c6976 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/fixture-projects/hook-initialization/test/test.ts @@ -0,0 +1,16 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- allow in fixture projects +import { AssertionError, expect } from "chai"; + +describe("chai-matcher-test", () => { + it("should pass", () => { + expect("0x0000010AB").to.not.hexEqual("0x0010abc"); + + expect( + () => + expect("0x28FAA621c3348823D6c6548981a19716bcDc740").to.be.properAddress, + ).to.throw( + AssertionError, + 'Expected "0x28FAA621c3348823D6c6548981a19716bcDc740" to be a proper address', + ); + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/helpers/contracts.ts b/v-next/hardhat-chai-matchers/test/helpers/contracts.ts new file mode 100644 index 0000000000..c3698be5ec --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/helpers/contracts.ts @@ -0,0 +1,146 @@ +import type { + BaseContract, + BaseContractMethod, + ContractTransactionResponse, + BigNumberish, + AddressLike, +} from "ethers"; + +export type MatchersContract = BaseContract & { + panicAssert: BaseContractMethod<[], void, ContractTransactionResponse>; + revertWithCustomErrorWithInt: BaseContractMethod< + [BigNumberish], + void, + ContractTransactionResponse + >; + revertWithCustomErrorWithPair: BaseContractMethod< + [BigNumberish, BigNumberish], + void, + ContractTransactionResponse + >; + revertWithCustomErrorWithUint: BaseContractMethod< + [BigNumberish], + void, + ContractTransactionResponse + >; + revertWithCustomErrorWithUintAndString: BaseContractMethod< + [BigNumberish, string], + void, + ContractTransactionResponse + >; + revertWithSomeCustomError: BaseContractMethod< + [], + void, + ContractTransactionResponse + >; + revertsWith: BaseContractMethod<[string], void, ContractTransactionResponse>; + revertsWithoutReason: BaseContractMethod< + [], + void, + ContractTransactionResponse + >; + succeeds: BaseContractMethod<[], void, ContractTransactionResponse>; +}; + +export type ChangeEtherBalance = BaseContract & { + returnHalf: BaseContractMethod<[], void, ContractTransactionResponse>; + transferTo: BaseContractMethod<[string], void, ContractTransactionResponse>; +}; + +export type EventsContract = BaseContract & { + doNotEmit: BaseContractMethod<[], void, ContractTransactionResponse>; + emitBytes32: BaseContractMethod<[string], void, ContractTransactionResponse>; + emitBytes32Array: BaseContractMethod< + [string, string], + void, + ContractTransactionResponse + >; + emitBytes: BaseContractMethod<[string], void, ContractTransactionResponse>; + emitIndexedBytes32: BaseContractMethod< + [string], + void, + ContractTransactionResponse + >; + emitIndexedBytes: BaseContractMethod< + [string], + void, + ContractTransactionResponse + >; + emitIndexedString: BaseContractMethod< + [string], + void, + ContractTransactionResponse + >; + emitInt: BaseContractMethod< + [BigNumberish], + void, + ContractTransactionResponse + >; + emitAddress: BaseContractMethod< + [AddressLike], + void, + ContractTransactionResponse + >; + emitNestedUintFromAnotherContract: BaseContractMethod< + [BigNumberish], + void, + ContractTransactionResponse + >; + emitNestedUintFromSameContract: BaseContractMethod< + [BigNumberish], + void, + ContractTransactionResponse + >; + emitString: BaseContractMethod<[string], void, ContractTransactionResponse>; + emitStruct: BaseContractMethod< + [BigNumberish, BigNumberish], + void, + ContractTransactionResponse + >; + emitTwoUints: BaseContractMethod< + [BigNumberish, BigNumberish], + void, + ContractTransactionResponse + >; + emitTwoUintsAndTwoStrings: BaseContractMethod< + [BigNumberish, BigNumberish, string, string], + void, + ContractTransactionResponse + >; + emitUint: BaseContractMethod< + [BigNumberish], + void, + ContractTransactionResponse + >; + emitUintAndString: BaseContractMethod< + [BigNumberish, string], + void, + ContractTransactionResponse + >; + emitUintArray: BaseContractMethod< + [BigNumberish, BigNumberish], + void, + ContractTransactionResponse + >; + emitUintTwice: BaseContractMethod< + [BigNumberish, BigNumberish], + void, + ContractTransactionResponse + >; + emitWithoutArgs: BaseContractMethod<[], void, ContractTransactionResponse>; +}; + +export type AnotherContract = BaseContract & {}; + +export type OverrideEventContract = BaseContract & { + emitSimpleEventWithUintArg: BaseContractMethod< + [BigNumberish], + void, + ContractTransactionResponse + >; + emitSimpleEventWithoutArg: BaseContractMethod< + [], + void, + ContractTransactionResponse + >; +}; diff --git a/v-next/hardhat-chai-matchers/test/helpers/helpers.ts b/v-next/hardhat-chai-matchers/test/helpers/helpers.ts new file mode 100644 index 0000000000..34908cca81 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/helpers/helpers.ts @@ -0,0 +1,141 @@ +import type { MatchersContract } from "./contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; +import type { ContractTransactionResponse } from "ethers/contract"; + +import { pathToFileURL } from "node:url"; + +import { createHardhatRuntimeEnvironment } from "@ignored/hardhat-vnext/hre"; +import { AssertionError, expect } from "chai"; + +export async function initEnvironment(_artifactsPath: string): Promise<{ + provider: EthereumProvider; + ethers: HardhatEthers; +}> { + const configPath = pathToFileURL( + `${process.cwd()}/hardhat.config.ts`, + ).toString(); + const config = (await import(configPath)).default; + + const hre = await createHardhatRuntimeEnvironment(config); + + const { ethers, provider } = await hre.network.connect(); + + return { provider, ethers }; +} + +/** + * Call `method` as: + * - A write transaction + * - A view method + * - A gas estimation + * - A static call + * And run the `successfulAssert` function with the result of each of these + * calls. Since we expect this assertion to be successful, we just await its + * result; if any of them fails, an error will be thrown. + */ +export async function runSuccessfulAsserts({ + matchers, + method, + args = [], + successfulAssert, +}: { + matchers: any; + method: string; + args?: any[]; + successfulAssert: (x: any) => Promise; +}): Promise { + await successfulAssert(matchers[method](...args)); + await successfulAssert(matchers[`${method}View`](...args)); + await successfulAssert(matchers[method].estimateGas(...args)); + await successfulAssert(matchers[method].staticCall(...args)); +} + +/** + * Similar to runSuccessfulAsserts, but check that the result of the assertion + * is an AssertionError with the given reason. + */ +export async function runFailedAsserts({ + matchers, + method, + args = [], + failedAssert, + failedAssertReason, +}: { + matchers: any; + method: string; + args?: any[]; + failedAssert: (x: any) => Promise; + failedAssertReason: string; +}): Promise { + await expect(failedAssert(matchers[method](...args))).to.be.rejectedWith( + AssertionError, + failedAssertReason, + ); + await expect( + failedAssert(matchers[`${method}View`](...args)), + ).to.be.rejectedWith(AssertionError, failedAssertReason); + await expect( + failedAssert(matchers[method].estimateGas(...args)), + ).to.be.rejectedWith(AssertionError, failedAssertReason); + await expect( + failedAssert(matchers[method].staticCall(...args)), + ).to.be.rejectedWith(AssertionError, failedAssertReason); +} + +export async function mineSuccessfulTransaction( + provider: EthereumProvider, + ethers: HardhatEthers, +): Promise { + await provider.request({ method: "evm_setAutomine", params: [false] }); + + const [signer] = await ethers.getSigners(); + const tx = await signer.sendTransaction({ to: signer.address }); + + await mineBlocksUntilTxIsIncluded(provider, ethers, tx.hash); + + await provider.request({ method: "evm_setAutomine", params: [true] }); + + return tx; +} + +export async function mineRevertedTransaction( + provider: EthereumProvider, + ethers: HardhatEthers, + matchers: MatchersContract, +): Promise { + await provider.request({ method: "evm_setAutomine", params: [false] }); + + const tx = await matchers.revertsWithoutReason({ + gasLimit: 1_000_000, + }); + + await mineBlocksUntilTxIsIncluded(provider, ethers, tx.hash); + + await provider.request({ method: "evm_setAutomine", params: [true] }); + + return tx; +} + +async function mineBlocksUntilTxIsIncluded( + provider: EthereumProvider, + ethers: HardhatEthers, + txHash: string, +) { + let i = 0; + + while (true) { + const receipt = await ethers.provider.getTransactionReceipt(txHash); + + if (receipt !== null) { + return; + } + + await provider.request({ method: "hardhat_mine", params: [] }); + + i++; + if (i > 100) { + throw new Error(`Transaction was not mined after mining ${i} blocks`); + } + } +} diff --git a/v-next/hardhat-chai-matchers/test/helpers/pretest.ts b/v-next/hardhat-chai-matchers/test/helpers/pretest.ts new file mode 100644 index 0000000000..f5fba4d019 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/helpers/pretest.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-restricted-syntax -- top-level await should be allowed in ESM */ + +import path, { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createHardhatRuntimeEnvironment } from "@ignored/hardhat-vnext/hre"; + +// Compile the contracts before executing the tests to bypass the compilation step during testing. + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +const fixtureProjectDir = path.join( + currentDir, + "..", + "fixture-projects", + "hardhat-project", +); + +const hre = await createHardhatRuntimeEnvironment({ + solidity: { + version: "0.8.24", // Same version as the one in the contracts in the "hardhat-project" fixture project + }, + paths: { + artifacts: path.join(fixtureProjectDir, "artifacts"), + sources: path.join(fixtureProjectDir, "contracts"), + }, +}); + +await hre.tasks.getTask("compile").run({}); diff --git a/v-next/hardhat-chai-matchers/test/index.ts b/v-next/hardhat-chai-matchers/test/index.ts new file mode 100644 index 0000000000..d9ee0f437a --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/index.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createHardhatRuntimeEnvironment } from "@ignored/hardhat-vnext/hre"; +import { useFixtureProject } from "@nomicfoundation/hardhat-test-utils"; + +describe("hardhat-chai-matchers plugin correctly initialized", () => { + useFixtureProject("hook-initialization"); + + it("should load the plugin via hook and use the functionalities in a mocha test", async () => { + const hardhatConfig = await import( + // eslint-disable-next-line import/no-relative-packages -- allow for fixture projects + "./fixture-projects/hook-initialization/hardhat.config.js" + ); + + const hre = await createHardhatRuntimeEnvironment(hardhatConfig.default); + + await hre.network.connect(); + + const result = await hre.tasks.getTask(["test", "mocha"]).run({ + testFiles: ["./test/test.ts"], + noCompile: true, + }); + + assert.equal(result, 0); + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/addressable.ts b/v-next/hardhat-chai-matchers/test/matchers/addressable.ts new file mode 100644 index 0000000000..30fc8a1bb9 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/addressable.ts @@ -0,0 +1,86 @@ +import { describe, it } from "node:test"; + +import { AssertionError, expect } from "chai"; +import { ethers } from "ethers"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; + +addChaiMatchers(); + +describe("Addressable matcher", () => { + const signer = ethers.Wallet.createRandom(); + const address = signer.address; + const contract = new ethers.Contract(address, []); + const typedAddress = ethers.Typed.address(address); + const typedSigner = ethers.Typed.address(signer); + const typedContract = ethers.Typed.address(contract); + + const otherSigner = ethers.Wallet.createRandom(); + const otherAddress = otherSigner.address; + const otherContract = new ethers.Contract(otherAddress, []); + const otherTypedAddress = ethers.Typed.address(otherAddress); + const otherTypedSigner = ethers.Typed.address(otherSigner); + const otherTypedContract = ethers.Typed.address(otherContract); + + const elements = [ + { name: "address", object: address, class: address }, + { name: "signer", object: signer, class: address }, + { name: "contract", object: contract, class: address }, + { name: "typed address", object: typedAddress, class: address }, + { name: "typed signer", object: typedSigner, class: address }, + { name: "typed contract", object: typedContract, class: address }, + { name: "other address", object: otherAddress, class: otherAddress }, + { name: "other signer", object: otherSigner, class: otherAddress }, + { name: "other contract", object: otherContract, class: otherAddress }, + { + name: "other typed address", + object: otherTypedAddress, + class: otherAddress, + }, + { + name: "other typed signer", + object: otherTypedSigner, + class: otherAddress, + }, + { + name: "other typed contract", + object: otherTypedContract, + class: otherAddress, + }, + ]; + + for (const el1 of elements) + for (const el2 of elements) { + const expectEqual = el1.class === el2.class; + + describe(`expect "${el1.name}" to equal "${el2.name}"`, () => { + if (expectEqual) { + it("should not revert", () => { + expect(el1.object).to.equal(el2.object); + }); + } else { + it("should revert", () => { + expect(() => expect(el1.object).to.equal(el2.object)).to.throw( + AssertionError, + `expected '${el1.class}' to equal '${el2.class}'.`, + ); + }); + } + }); + + describe(`expect "${el1.name}" to not equal "${el1.name}"`, () => { + if (expectEqual) { + it("should revert", () => { + expect(() => expect(el1.object).to.not.equal(el2.object)).to.throw( + AssertionError, + `expected '${el1.class}' to not equal '${el2.class}'.`, + ); + }); + } else { + it("should not revert", () => { + expect(el1.object).to.not.equal(el2.object); + }); + } + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/big-number.ts b/v-next/hardhat-chai-matchers/test/matchers/big-number.ts new file mode 100644 index 0000000000..4a8d39c37f --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/big-number.ts @@ -0,0 +1,1222 @@ +import { describe, it } from "node:test"; + +import { InvalidParameterError } from "@ignored/hardhat-vnext-utils/common-errors"; +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; + +type SupportedNumber = number | bigint; + +const numberToBigNumberConversions = [(n: number) => BigInt(n)]; + +addChaiMatchers(); + +describe("BigNumber matchers", () => { + function typestr(n: string | SupportedNumber): string { + return typeof n; + } + + describe("length", () => { + const lengthFunctions: Array< + keyof Chai.Assertion & ("length" | "lengthOf") + > = ["length", "lengthOf"]; + + interface SuccessCase { + obj: object; + len: number; + } + + const positiveSuccessCases: SuccessCase[] = [ + { obj: [1, 2, 3], len: 3 }, + { + obj: new Map([ + [1, 2], + [3, 4], + ]), + len: 2, + }, + { obj: new Set([1, 2, 3]), len: 3 }, + ]; + describe("positive, successful assertions", () => { + for (const { obj, len } of positiveSuccessCases) { + for (const convert of [ + (n: number) => n, + ...numberToBigNumberConversions, + ]) { + const length = convert(len); + + describe(`with object ${obj.toString()} and with length operand of type ${typestr( + length, + )}`, () => { + for (const lenFunc of lengthFunctions) { + it(`.to.have.${lenFunc} should work`, () => { + expect(obj).to.have[lenFunc](length); + }); + } + }); + } + } + }); + + const negativeSuccessCases: SuccessCase[] = [ + { obj: [1, 2, 3], len: 2 }, + { + obj: new Map([ + [1, 2], + [3, 4], + ]), + len: 3, + }, + { obj: new Set([1, 2, 3]), len: 4 }, + ]; + describe("negative, successful assertions", () => { + for (const { obj, len } of negativeSuccessCases) { + for (const convert of [ + (n: number) => n, + ...numberToBigNumberConversions, + ]) { + const length = convert(len); + describe(`with object ${obj.toString()} and with length operand of type ${typestr( + length, + )}`, () => { + for (const lenFunc of lengthFunctions) { + it(`should work with .not.to.have.${lenFunc}`, () => { + expect(obj).not.to.have[lenFunc](length); + }); + } + }); + } + } + }); + + interface FailureCase extends SuccessCase { + msg: string | RegExp; + } + + const positiveFailureCases: FailureCase[] = [ + { + obj: [1, 2, 3], + len: 2, + msg: "expected [ 1, 2, 3 ] to have a length of 2 but got 3", + }, + { + obj: new Set([1, 2, 3]), + len: 2, + msg: /expected .* to have a size of 2 but got 3/, + }, + { + obj: new Map([ + [1, 2], + [3, 4], + ]), + len: 3, + msg: /expected .* to have a size of 3 but got 2/, + }, + ]; + describe("positive, failing assertions should throw the proper error message", () => { + for (const { obj, len, msg } of positiveFailureCases) { + for (const convert of [ + (n: number) => n, + ...numberToBigNumberConversions, + ]) { + const length = convert(len); + describe(`with object ${obj.toString()} and with operand of type ${typestr( + length, + )}`, () => { + for (const lenFunc of lengthFunctions) { + it(`should work with .to.have.${lenFunc}`, () => { + expect(() => expect(obj).to.have[lenFunc](length)).to.throw( + AssertionError, + msg, + ); + }); + } + }); + } + } + }); + + const operators = [ + "above", + "below", + "gt", + "lt", + "greaterThan", + "lessThan", + "least", + "most", + "gte", + "lte", + "greaterThanOrEqual", + "lessThanOrEqual", + ] as const; + type Operator = (typeof operators)[number]; + + interface SuccessCaseWithOperator extends SuccessCase { + operator: Operator; + } + + const positiveSuccessCasesWithOperator: SuccessCaseWithOperator[] = [ + { operator: "lt", len: 4, obj: [1, 2, 3] }, + { operator: "lt", len: 4, obj: new Set([1, 2, 3]) }, + { + operator: "lt", + len: 4, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "above", len: 2, obj: [1, 2, 3] }, + { operator: "above", len: 2, obj: new Set([1, 2, 3]) }, + { + operator: "above", + len: 2, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "gt", len: 2, obj: [1, 2, 3] }, + { operator: "gt", len: 2, obj: new Set([1, 2, 3]) }, + { + operator: "gt", + len: 2, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "greaterThan", len: 2, obj: [1, 2, 3] }, + { operator: "greaterThan", len: 2, obj: new Set([1, 2, 3]) }, + { + operator: "greaterThan", + len: 2, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "least", len: 3, obj: [1, 2, 3] }, + { operator: "least", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "least", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "most", len: 3, obj: [1, 2, 3] }, + { operator: "most", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "most", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "gte", len: 3, obj: [1, 2, 3] }, + { operator: "gte", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "gte", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "lte", len: 3, obj: [1, 2, 3] }, + { operator: "lte", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "lte", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "greaterThanOrEqual", len: 3, obj: [1, 2, 3] }, + { operator: "greaterThanOrEqual", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "greaterThanOrEqual", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "lessThanOrEqual", len: 3, obj: [1, 2, 3] }, + { operator: "lessThanOrEqual", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "lessThanOrEqual", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + ]; + describe("positive, successful assertions chained off of length", () => { + for (const { obj, operator, len } of positiveSuccessCasesWithOperator) { + describe(`with object ${JSON.stringify(obj)} and operator "${operator}"`, () => { + for (const convert of [ + (n: number) => n, + ...numberToBigNumberConversions, + ]) { + const length = convert(len); + describe(`with an operand of type ${typestr(length)}`, () => { + for (const lenFunc of lengthFunctions) { + it(`should work with .to.have.${lenFunc}.${operator}`, () => { + expect(obj).to.have[lenFunc][operator](length); + }); + } + }); + } + }); + } + }); + + const negativeSuccessCasesWithOperator: SuccessCaseWithOperator[] = [ + { operator: "above", len: 3, obj: [1, 2, 3] }, + { operator: "above", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "above", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "below", len: 3, obj: [1, 2, 3] }, + { operator: "below", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "below", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "gt", len: 3, obj: [1, 2, 3] }, + { operator: "gt", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "gt", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "lt", len: 3, obj: [1, 2, 3] }, + { operator: "lt", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "lt", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "greaterThan", len: 3, obj: [1, 2, 3] }, + { operator: "greaterThan", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "greaterThan", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "lessThan", len: 3, obj: [1, 2, 3] }, + { operator: "lessThan", len: 3, obj: new Set([1, 2, 3]) }, + { + operator: "lessThan", + len: 3, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "least", len: 4, obj: [1, 2, 3] }, + { operator: "least", len: 4, obj: new Set([1, 2, 3]) }, + { + operator: "least", + len: 4, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "most", len: 2, obj: [1, 2, 3] }, + { operator: "most", len: 2, obj: new Set([1, 2, 3]) }, + { + operator: "most", + len: 2, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "gte", len: 4, obj: [1, 2, 3] }, + { operator: "gte", len: 4, obj: new Set([1, 2, 3]) }, + { + operator: "gte", + len: 4, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "lte", len: 2, obj: [1, 2, 3] }, + { operator: "lte", len: 2, obj: new Set([1, 2, 3]) }, + { + operator: "lte", + len: 2, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "greaterThanOrEqual", len: 4, obj: [1, 2, 3] }, + { operator: "greaterThanOrEqual", len: 4, obj: new Set([1, 2, 3]) }, + { + operator: "greaterThanOrEqual", + len: 4, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + { operator: "lessThanOrEqual", len: 2, obj: [1, 2, 3] }, + { operator: "lessThanOrEqual", len: 2, obj: new Set([1, 2, 3]) }, + { + operator: "lessThanOrEqual", + len: 2, + obj: new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + }, + ]; + describe("negative, successful assertions chained off of length", () => { + for (const { obj, operator, len } of negativeSuccessCasesWithOperator) { + describe(`with object ${JSON.stringify(obj)} and operator "${operator}"`, () => { + for (const convert of [ + (n: number) => n, + ...numberToBigNumberConversions, + ]) { + const length = convert(len); + describe(`with an operand of type ${typestr(length)}`, () => { + for (const lenFunc of lengthFunctions) { + it(`should work with .not.to.have.${lenFunc}.${operator}`, () => { + expect(obj).not.to.have[lenFunc][operator](length); + }); + } + }); + } + }); + } + }); + + interface FailureCaseWithOperator extends SuccessCaseWithOperator { + msg: string; + } + + const positiveFailureCasesWithOperator: FailureCaseWithOperator[] = [ + { + obj: [1, 2, 3], + operator: "above", + len: 3, + msg: "expected [ 1, 2, 3 ] to have a length above 3 but got 3", + }, + { + obj: [1, 2, 3], + operator: "below", + len: 3, + msg: "expected [ 1, 2, 3 ] to have a length below 3 but got 3", + }, + { + obj: [1, 2, 3], + operator: "gt", + len: 3, + msg: "expected [ 1, 2, 3 ] to have a length above 3 but got 3", + }, + { + obj: [1, 2, 3], + operator: "lt", + len: 3, + msg: "expected [ 1, 2, 3 ] to have a length below 3 but got 3", + }, + { + obj: [1, 2, 3], + operator: "greaterThan", + len: 3, + msg: "expected [ 1, 2, 3 ] to have a length above 3 but got 3", + }, + { + obj: [1, 2, 3], + operator: "lessThan", + len: 3, + msg: "expected [ 1, 2, 3 ] to have a length below 3 but got 3", + }, + { + obj: [1, 2, 3], + operator: "least", + len: 4, + msg: "expected [ 1, 2, 3 ] to have a length at least 4 but got 3", + }, + { + obj: [1, 2, 3], + operator: "most", + len: 2, + msg: "expected [ 1, 2, 3 ] to have a length at most 2 but got 3", + }, + { + obj: [1, 2, 3], + operator: "gte", + len: 4, + msg: "expected [ 1, 2, 3 ] to have a length at least 4 but got 3", + }, + { + obj: [1, 2, 3], + operator: "lte", + len: 2, + msg: "expected [ 1, 2, 3 ] to have a length at most 2 but got 3", + }, + { + obj: [1, 2, 3], + operator: "greaterThanOrEqual", + len: 4, + msg: "expected [ 1, 2, 3 ] to have a length at least 4 but got 3", + }, + { + obj: [1, 2, 3], + operator: "lessThanOrEqual", + len: 2, + msg: "expected [ 1, 2, 3 ] to have a length at most 2 but got 3", + }, + ]; + describe("positive, failing assertions chained off of length should throw the proper error message", () => { + for (const { + obj, + operator, + len, + msg, + } of positiveFailureCasesWithOperator) { + describe(`with object ${JSON.stringify(obj)} and operator "${operator}"`, () => { + for (const convert of [ + (n: number) => n, + ...numberToBigNumberConversions, + ]) { + const length = convert(len); + describe(`with an operand of type ${typestr(length)}`, () => { + for (const lenFunc of lengthFunctions) { + it(`should work with .to.have.${lenFunc}.${operator}`, () => { + expect(() => + expect(obj).to.have[lenFunc][operator](length), + ).to.throw(AssertionError, msg); + }); + } + }); + } + }); + } + }); + }); + + describe("with two arguments", () => { + function checkAll( + actual: number, + expected: number, + test: ( + actual: string | SupportedNumber, + expected: string | SupportedNumber, + ) => void, + ) { + const conversions = [ + (n: number) => n, + (n: number) => n.toString(), + ...numberToBigNumberConversions, + ]; + for (const convertActual of conversions) { + for (const convertExpected of conversions) { + const convertedActual = convertActual(actual); + const convertedExpected = convertExpected(expected); + // a few particular combinations of types don't work: + if (typeof convertedActual === "string") { + continue; + } + if ( + typeof convertedActual === "number" && + typeof convertedExpected === "string" + ) { + continue; + } + test(convertedActual, convertedExpected); + } + } + } + + const operators = [ + "equals", + "equal", + "eq", + "above", + "below", + "gt", + "lt", + "greaterThan", + "lessThan", + "least", + "most", + "gte", + "lte", + "greaterThanOrEqual", + "lessThanOrEqual", + ] as const; + type Operator = (typeof operators)[number]; + + interface SuccessCase { + operator: Operator; + operands: [number, number]; + } + + interface FailureCase extends SuccessCase { + msg: string; + } + + const positiveSuccessCases: SuccessCase[] = [ + { operands: [10, 10], operator: "eq" }, + { operands: [10, 10], operator: "equal" }, + { operands: [10, 10], operator: "equals" }, + { operands: [10, 9], operator: "above" }, + { operands: [10, 9], operator: "gt" }, + { operands: [10, 9], operator: "greaterThan" }, + { operands: [10, 11], operator: "below" }, + { operands: [10, 11], operator: "lt" }, + { operands: [10, 11], operator: "lessThan" }, + { operands: [10, 10], operator: "least" }, + { operands: [10, 10], operator: "gte" }, + { operands: [10, 10], operator: "greaterThanOrEqual" }, + { operands: [10, 9], operator: "least" }, + { operands: [10, 9], operator: "gte" }, + { operands: [10, 9], operator: "greaterThanOrEqual" }, + { operands: [10, 10], operator: "most" }, + { operands: [10, 10], operator: "lte" }, + { operands: [10, 10], operator: "lessThanOrEqual" }, + { operands: [10, 11], operator: "most" }, + { operands: [10, 11], operator: "lte" }, + { operands: [10, 11], operator: "lessThanOrEqual" }, + ]; + for (const { operator, operands } of positiveSuccessCases) { + describe(`.to.${operator}`, () => { + checkAll(operands[0], operands[1], (a, b) => { + it(`should work with ${typestr(a)} and ${typestr(b)}`, () => { + expect(a).to[operator](b); + }); + }); + }); + } + + const eqPositiveFailureCase: Omit = { + operands: [10, 11], + msg: "expected 10 to equal 11", + }; + const gtPositiveFailureCase: Omit = { + operands: [10, 10], + msg: "expected 10 to be above 10", + }; + const ltPositiveFailureCase: Omit = { + operands: [11, 10], + msg: "expected 11 to be below 10", + }; + const gtePositiveFailureCase: Omit = { + operands: [10, 11], + msg: "expected 10 to be at least 11", + }; + const ltePositiveFailureCase: Omit = { + operands: [11, 10], + msg: "expected 11 to be at most 10", + }; + const positiveFailureCases: FailureCase[] = [ + { ...eqPositiveFailureCase, operator: "eq" }, + { ...eqPositiveFailureCase, operator: "equal" }, + { ...eqPositiveFailureCase, operator: "equals" }, + { ...gtPositiveFailureCase, operator: "above" }, + { ...gtPositiveFailureCase, operator: "gt" }, + { ...gtPositiveFailureCase, operator: "greaterThan" }, + { ...ltPositiveFailureCase, operator: "below" }, + { ...ltPositiveFailureCase, operator: "lt" }, + { ...ltPositiveFailureCase, operator: "lessThan" }, + { ...gtePositiveFailureCase, operator: "least" }, + { ...gtePositiveFailureCase, operator: "gte" }, + { ...gtePositiveFailureCase, operator: "greaterThanOrEqual" }, + { ...ltePositiveFailureCase, operator: "most" }, + { ...ltePositiveFailureCase, operator: "lte" }, + { ...ltePositiveFailureCase, operator: "lessThanOrEqual" }, + ]; + for (const { operator, operands, msg } of positiveFailureCases) { + describe(`.to.${operator} should throw the proper message on failure`, () => { + checkAll(operands[0], operands[1], (a, b) => { + it(`with ${typestr(a)} and ${typestr(b)}`, () => { + expect(() => expect(a).to[operator](b)).to.throw( + AssertionError, + msg, + ); + }); + }); + }); + } + + const negativeSuccessCases: SuccessCase[] = [ + { operands: [11, 10], operator: "eq" }, + { operands: [11, 10], operator: "equal" }, + { operands: [11, 10], operator: "equals" }, + { operands: [10, 10], operator: "above" }, + { operands: [10, 10], operator: "gt" }, + { operands: [10, 10], operator: "greaterThan" }, + { operands: [10, 10], operator: "below" }, + { operands: [10, 10], operator: "lt" }, + { operands: [10, 10], operator: "lessThan" }, + { operands: [10, 9], operator: "below" }, + { operands: [10, 9], operator: "lt" }, + { operands: [10, 9], operator: "lessThan" }, + { operands: [10, 11], operator: "least" }, + { operands: [10, 11], operator: "gte" }, + { operands: [10, 11], operator: "greaterThanOrEqual" }, + { operands: [10, 9], operator: "most" }, + { operands: [10, 9], operator: "lte" }, + { operands: [10, 9], operator: "lessThanOrEqual" }, + ]; + for (const { operator, operands } of negativeSuccessCases) { + describe(`.not.to.${operator}`, () => { + checkAll(operands[0], operands[1], (a, b) => { + it(`should work with ${typestr(a)} and ${typestr(b)}`, () => { + expect(a).not.to[operator](b); + }); + }); + }); + } + + const gtNegativeFailureCase: Omit = { + operands: [11, 10], + msg: "expected 11 to be at most 10", + }; + const eqNegativeFailureCase: Omit = { + operands: [10, 10], + msg: "expected 10 to not equal 10", + }; + const ltNegativeFailureCase: Omit = { + operands: [10, 11], + msg: "expected 10 to be at least 11", + }; + const gteNegativeFailureCase: Omit = { + operands: [11, 10], + msg: "expected 11 to be below 10", + }; + const lteNegativeFailureCase: Omit = { + operands: [10, 11], + msg: "expected 10 to be above 11", + }; + const negativeFailureCases: FailureCase[] = [ + { ...eqNegativeFailureCase, operator: "eq" }, + { ...eqNegativeFailureCase, operator: "equal" }, + { ...eqNegativeFailureCase, operator: "equals" }, + { ...gtNegativeFailureCase, operator: "above" }, + { ...gtNegativeFailureCase, operator: "gt" }, + { ...gtNegativeFailureCase, operator: "greaterThan" }, + { ...ltNegativeFailureCase, operator: "below" }, + { ...ltNegativeFailureCase, operator: "lt" }, + { ...ltNegativeFailureCase, operator: "lessThan" }, + { ...gteNegativeFailureCase, operator: "least" }, + { ...gteNegativeFailureCase, operator: "gte" }, + { ...gteNegativeFailureCase, operator: "greaterThanOrEqual" }, + { ...lteNegativeFailureCase, operator: "most" }, + { ...lteNegativeFailureCase, operator: "lte" }, + { ...lteNegativeFailureCase, operator: "lessThanOrEqual" }, + ]; + for (const { operator, operands, msg } of negativeFailureCases) { + describe("should throw the proper message on failure", () => { + checkAll(operands[0], operands[1], (a, b) => { + it(`with ${typestr(a)} and ${typestr(b)}`, () => { + expect(() => expect(a).not.to[operator](b)).to.throw( + AssertionError, + msg, + ); + }); + }); + }); + } + + operators.forEach((operator: Operator) => { + describe("should throw when comparing to a non-integral floating point literal", () => { + for (const convert of numberToBigNumberConversions) { + const converted = convert(1); + const msg = "1.1 is not an integer"; + it(`with .to.${operator} comparing float vs ${typestr( + converted, + )}`, () => { + expect(() => expect(1.1).to[operator](converted)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it(`with .to.${operator} comparing ${typestr( + converted, + )} vs float`, () => { + expect(() => expect(converted).to[operator](1.1)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it(`with .not.to.${operator} comparing float vs ${typestr( + converted, + )}`, () => { + expect(() => expect(1.1).not.to[operator](converted)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it(`with .not.to.${operator} comparing ${typestr( + converted, + )} vs float`, () => { + expect(() => expect(converted).not.to[operator](1.1)).to.throw( + InvalidParameterError, + msg, + ); + }); + } + }); + + describe("should throw when comparing to an unsafe integer", () => { + const unsafeInt = 1e16; + const msg = `Integer 10000000000000000 is unsafe. Consider using ${unsafeInt}n instead. For more details, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger`; + + describe(`when using .to.${operator}`, () => { + it("with an unsafe int as the first param", () => { + expect(() => expect(unsafeInt).to[operator](1n)).to.throw( + InvalidParameterError, + msg, + ); + }); + it("with an unsafe int as the second param", () => { + expect(() => expect(1n).to[operator](unsafeInt)).to.throw( + InvalidParameterError, + msg, + ); + }); + }); + + describe(`when using .not.to.${operator}`, () => { + it("with an unsafe int as the first param", () => { + expect(() => expect(unsafeInt).not.to[operator](1n)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it("with an unsafe int as the second param", () => { + expect(() => expect(1n).not.to[operator](unsafeInt)).to.throw( + InvalidParameterError, + msg, + ); + }); + }); + }); + }); + + describe("deep equal", () => { + checkAll(1, 1, (a, b) => { + it(`should work with ${typestr(a)} and ${typestr(b)}`, () => { + // successful assertions + expect([a]).to.deep.equal([b]); + expect([[a], [a]]).to.deep.equal([[b], [b]]); + expect({ x: a }).to.deep.equal({ x: b }); + expect({ x: { y: a } }).to.deep.equal({ x: { y: b } }); + expect({ x: [a] }).to.deep.equal({ x: [b] }); + // failed assertions + // We are not checking the content of the arrays/objects because + // it depends on the type of the numbers (plain numbers, native + // bigints) + // Ideally the output would be normalized and we could check the + // actual content more easily. + expect(() => expect([a]).to.not.deep.equal([b])).to.throw( + AssertionError, + // the 's' modifier is used to make . match newlines too + /expected \[.*\] to not deeply equal \[.*\]/s, + ); + expect(() => + expect([[a], [a]]).to.not.deep.equal([[b], [b]]), + ).to.throw( + AssertionError, + /expected \[.*\] to not deeply equal \[.*\]/s, + ); + expect(() => expect({ x: a }).to.not.deep.equal({ x: b })).to.throw( + AssertionError, + /expected \{.*\} to not deeply equal \{.*\}/s, + ); + expect(() => + expect({ x: { y: a } }).to.not.deep.equal({ x: { y: b } }), + ).to.throw( + AssertionError, + /expected \{.*\} to not deeply equal \{.*\}/s, + ); + expect(() => + expect({ x: [a] }).to.not.deep.equal({ x: [b] }), + ).to.throw( + AssertionError, + /expected \{.*\} to not deeply equal \{.*\}/s, + ); + }); + }); + + checkAll(1, 2, (a, b) => { + it(`should work with ${typestr(a)} and ${typestr( + b, + )} (negative)`, () => { + // successful assertions + expect([a]).to.not.deep.equal([b]); + expect([[a], [a]]).to.not.deep.equal([[b], [b]]); + expect({ x: a }).to.not.deep.equal({ x: b }); + expect({ x: { y: a } }).to.not.deep.equal({ x: { y: b } }); + expect({ x: [a] }).to.not.deep.equal({ x: [b] }); + // failed assertions + expect(() => expect([a]).to.deep.equal([b])).to.throw( + AssertionError, + // the 's' modifier is used to make . match newlines too + /expected \[.*\] to deeply equal \[.*\]/s, + ); + expect(() => expect([[a], [a]]).to.deep.equal([[b], [b]])).to.throw( + AssertionError, + /expected \[.*\] to deeply equal \[.*\]/s, + ); + expect(() => expect({ x: a }).to.deep.equal({ x: b })).to.throw( + AssertionError, + /expected \{.*\} to deeply equal \{.*\}/s, + ); + expect(() => + expect({ x: { y: a } }).to.deep.equal({ x: { y: b } }), + ).to.throw(AssertionError, /expected \{.*\} to deeply equal \{.*\}/s); + expect(() => expect({ x: [a] }).to.deep.equal({ x: [b] })).to.throw( + AssertionError, + /expected \{.*\} to deeply equal \{.*\}/s, + ); + }); + }); + }); + }); + + describe("with three arguments", () => { + function checkAll( + a: number, + b: number, + c: number, + test: ( + a: SupportedNumber, + b: SupportedNumber, + c: SupportedNumber, + ) => void, + ) { + const conversions = [(n: number) => n, ...numberToBigNumberConversions]; + for (const convertA of conversions) { + for (const convertB of conversions) { + for (const convertC of conversions) { + test(convertA(a), convertB(b), convertC(c)); + } + } + } + } + + const operators = ["within", "closeTo", "approximately"] as const; + type Operator = (typeof operators)[number]; + + interface SuccessCase { + operator: Operator; + operands: [number, number, number]; + } + + interface FailureCase extends SuccessCase { + msg: string; + } + + const positiveSuccessCases: SuccessCase[] = [ + { operator: "within", operands: [100, 99, 101] }, + { operator: "closeTo", operands: [101, 101, 10] }, + { operator: "approximately", operands: [101, 101, 10] }, + ]; + for (const { operator, operands } of positiveSuccessCases) { + describe(`.to.be.${operator}`, () => { + checkAll(operands[0], operands[1], operands[2], (a, b, c) => { + it(`should work with ${typestr(a)}, ${typestr(b)} and ${typestr( + c, + )}`, () => { + expect(a).to.be[operator](b, c); + }); + }); + }); + } + + const positiveFailureCases: FailureCase[] = [ + { + operator: "within", + operands: [100, 80, 90], + msg: "expected 100 to be within 80..90", + }, + { + operator: "closeTo", + operands: [100, 111, 10], + msg: "expected 100 to be close to 111 +/- 10", + }, + { + operator: "approximately", + operands: [100, 111, 10], + msg: "expected 100 to be close to 111 +/- 10", + }, + ]; + for (const { operator, operands, msg } of positiveFailureCases) { + describe(`.to.be.${operator} should throw the proper message on failure`, () => { + checkAll(operands[0], operands[1], operands[2], (a, b, c) => { + it(`with ${typestr(a)}, ${typestr(b)} and ${typestr(c)}`, () => { + expect(() => expect(a).to.be[operator](b, c)).to.throw( + AssertionError, + msg, + ); + }); + }); + }); + } + + const closeToAndApproximately: Operator[] = ["closeTo", "approximately"]; + for (const closeToOrApproximately of closeToAndApproximately) { + describe(`${closeToOrApproximately} with an undefined delta argument`, () => { + for (const convert of [ + (n: number) => n, + ...numberToBigNumberConversions, + ]) { + const one = convert(1); + + it(`with a ${typestr( + one, + )} actual should throw a helpful error message`, () => { + expect(() => + expect(one).to.be[closeToOrApproximately](100, undefined), + ).to.throw( + AssertionError, + "the arguments to closeTo or approximately must be numbers, and a delta is required", + ); + }); + } + }); + } + + const negativeSuccessCases: SuccessCase[] = [ + { operator: "within", operands: [100, 101, 102] }, + { operator: "within", operands: [100, 98, 99] }, + { operator: "closeTo", operands: [100, 111, 10] }, + { operator: "approximately", operands: [100, 111, 10] }, + ]; + for (const { operator, operands } of negativeSuccessCases) { + describe(`.not.to.be.${operator}`, () => { + checkAll(operands[0], operands[1], operands[2], (a, b, c) => { + it(`should work with ${typestr(a)}, ${typestr(b)} and ${typestr( + c, + )}`, () => { + expect(a).not.to.be[operator](b, c); + }); + }); + }); + } + + const negativeFailureCases: FailureCase[] = [ + { + operator: "within", + operands: [100, 99, 101], + msg: "expected 100 to not be within 99..101", + }, + { + operator: "closeTo", + operands: [100, 101, 10], + msg: "expected 100 not to be close to 101 +/- 10", + }, + { + operator: "approximately", + operands: [100, 101, 10], + msg: "expected 100 not to be close to 101 +/- 10", + }, + ]; + for (const { operator, operands, msg } of negativeFailureCases) { + describe(`.not.to.be.${operator} should throw the proper message on failure`, () => { + checkAll(operands[0], operands[1], operands[2], (a, b, c) => { + it(`with ${typestr(a)}, ${typestr(b)} and ${typestr(c)}`, () => { + expect(() => expect(a).not.to.be[operator](b, c)).to.throw( + AssertionError, + msg, + ); + }); + }); + }); + } + + operators.forEach((operator: Operator) => { + describe(`should throw when comparing to a non-integral floating point literal`, () => { + for (const convertA of numberToBigNumberConversions) { + for (const convertB of numberToBigNumberConversions) { + const a = convertA(1); + const b = convertB(1); + const msg = "1.1 is not an integer"; + + describe(`with .to.${operator}`, () => { + it(`with float, ${typestr(a)}, ${typestr(a)}`, () => { + expect(() => expect(1.1).to[operator](a, b)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it(`with ${typestr(a)}, float, ${typestr(b)}`, () => { + expect(() => expect(a).to[operator](1.1, b)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it(`with ${typestr(a)}, ${typestr(b)}, float`, () => { + expect(() => expect(a).to[operator](b, 1.1)).to.throw( + InvalidParameterError, + msg, + ); + }); + }); + + describe(`with not.to.${operator}`, () => { + it(`with float, ${typestr(a)}, ${typestr(a)}`, () => { + expect(() => expect(1.1).not.to[operator](a, b)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it(`with ${typestr(a)}, float, ${typestr(b)}`, () => { + expect(() => expect(a).not.to[operator](1.1, b)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it(`with ${typestr(a)}, ${typestr(b)}, float`, () => { + expect(() => expect(a).not.to[operator](b, 1.1)).to.throw( + InvalidParameterError, + msg, + ); + }); + }); + } + } + }); + + describe("should throw when comparing to an unsafe integer", () => { + const unsafeInt = 1e16; + const msg = `Integer 10000000000000000 is unsafe. Consider using ${unsafeInt}n instead. For more details, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger`; + + describe(`when using .to.${operator}`, () => { + it("with an unsafe int as the first param", () => { + expect(() => expect(unsafeInt).to[operator](1n, 1n)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it("with an unsafe int as the second param", () => { + expect(() => expect(1n).to[operator](unsafeInt, 1n)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it("with an unsafe int as the third param", () => { + expect(() => expect(1n).to[operator](1n, unsafeInt)).to.throw( + InvalidParameterError, + msg, + ); + }); + }); + + describe(`when using not.to.${operator}`, () => { + it("with an unsafe int as the first param", () => { + expect(() => expect(unsafeInt).not.to[operator](1n, 1n)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it("with an unsafe int as the second param", () => { + expect(() => expect(1n).not.to[operator](unsafeInt, 1n)).to.throw( + InvalidParameterError, + msg, + ); + }); + + it("with an unsafe int as the third param", () => { + expect(() => expect(1n).not.to[operator](1n, unsafeInt)).to.throw( + InvalidParameterError, + msg, + ); + }); + }); + }); + }); + }); + + it("custom message is preserved", () => { + // normal numbers + expect(() => expect(1).to.equal(2, "custom message")).to.throw( + AssertionError, + "custom message", + ); + + // number and bigint + expect(() => expect(1).to.equal(2n, "custom message")).to.throw( + AssertionError, + "custom message", + ); + + // same but for deep comparisons + expect(() => expect([1]).to.equal([2], "custom message")).to.throw( + AssertionError, + "custom message", + ); + + // number and bigint + expect(() => expect([1]).to.equal([2n], "custom message")).to.throw( + AssertionError, + "custom message", + ); + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/changeEtherBalance.ts b/v-next/hardhat-chai-matchers/test/matchers/changeEtherBalance.ts new file mode 100644 index 0000000000..5c835fb160 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/changeEtherBalance.ts @@ -0,0 +1,664 @@ +import type { Token } from "../../src/internal/matchers/changeTokenBalance.js"; +import type { ChangeEtherBalance } from "../helpers/contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { + HardhatEthers, + HardhatEthersSigner, +} from "@ignored/hardhat-vnext-ethers/types"; + +import path from "node:path"; +import { before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertThrowsHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { expect, AssertionError } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; +import { initEnvironment } from "../helpers/helpers.js"; + +addChaiMatchers(); + +describe("INTEGRATION: changeEtherBalance matcher", { timeout: 60000 }, () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + let sender: HardhatEthersSigner; + let receiver: HardhatEthersSigner; + let contract: ChangeEtherBalance; + let txGasFees: number; + let mockToken: Token; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + before(async () => { + ({ ethers, provider } = await initEnvironment("change-ether-balance")); + }); + + beforeEach(async () => { + const wallets = await ethers.getSigners(); + sender = wallets[0]; + receiver = wallets[1]; + + contract = await ( + await ethers.getContractFactory<[], ChangeEtherBalance>( + "ChangeEtherBalance", + ) + ).deploy(); + + txGasFees = 1 * 21_000; + + await provider.request({ + method: "hardhat_setNextBlockBaseFeePerGas", + params: ["0x0"], + }); + + const MockToken = await ethers.getContractFactory<[], Token>("MockToken"); + mockToken = await MockToken.deploy(); + }); + + describe("Transaction Callback (legacy tx)", () => { + describe("Change balance, one account", () => { + it("should pass when expected balance change is passed as string and is equal to an actual", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-200"); + }); + + it("should fail when block contains more than one transaction", async () => { + await provider.request({ + method: "evm_setAutomine", + params: [false], + }); + + // we set a gas limit to avoid using the whole block gas limit + await sender.sendTransaction({ + to: receiver.address, + value: 200, + gasLimit: 30_000, + }); + + await provider.request({ method: "evm_setAutomine", params: [true] }); + + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + gasLimit: 30_000, + }), + ).to.changeEtherBalance(provider, sender, -200, { + includeFee: true, + }), + ).to.be.eventually.rejectedWith( + "There should be only 1 transaction in the block", + ); + }); + + it("should pass when given an address as a string", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender.address, "-200"); + }); + + it("should pass when given a native bigint", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -200n); + }); + + it("should pass when given a predicate", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance( + provider, + sender, + (diff: bigint) => diff === -200n, + ); + }); + + it("should pass when expected balance change is passed as int and is equal to an actual", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, receiver, 200); + }); + + it("should take into account transaction fee", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -(txGasFees + 200), { + includeFee: true, + }); + }); + + it("should take into account transaction fee when given a predicate", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalance( + provider, + sender, + (diff: bigint) => diff === -(BigInt(txGasFees) + 200n), + { + includeFee: true, + }, + ); + }); + + it("should ignore fee if receiver's wallet is being checked and includeFee was set", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, receiver, 200, { + includeFee: true, + }); + }); + + it("should take into account transaction fee by default", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -200); + }); + + it("should pass on negative case when expected balance does not satisfy the predicate", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalance( + provider, + receiver, + (diff: bigint) => diff === 300n, + ); + }); + + it("should throw when fee was not calculated correctly", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -200, { + includeFee: true, + }), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${ + sender.address + }" to change by -200 wei, but it changed by -${txGasFees + 200} wei`, + ); + }); + + it("should throw when expected balance change value was different from an actual", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-500"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" to change by -500 wei, but it changed by -200 wei`, + ); + }); + + it("should throw when actual balance change value does not satisfy the predicate", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance( + provider, + sender, + (diff: bigint) => diff === -500n, + ), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance change of "${sender.address}" to satisfy the predicate, but it didn't (balance change: -200 wei)`, + ); + }); + + it("should throw in negative case when expected balance change value was equal to an actual", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalance(provider, sender, "-200"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" NOT to change by -200 wei, but it did`, + ); + }); + + it("should throw in negative case when expected balance change value satisfies the predicate", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalance( + provider, + sender, + (diff: bigint) => diff === -200n, + ), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance change of "${sender.address}" to NOT satisfy the predicate, but it did (balance change: -200 wei)`, + ); + }); + + it("should pass when given zero value tx", async () => { + await expect(() => + sender.sendTransaction({ to: receiver.address, value: 0 }), + ).to.changeEtherBalance(provider, sender, 0); + }); + + it("shouldn't run the transaction twice", async () => { + const receiverBalanceBefore: bigint = + await ethers.provider.getBalance(receiver); + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -200); + const receiverBalanceAfter: bigint = + await ethers.provider.getBalance(receiver); + const receiverBalanceChange = + receiverBalanceAfter - receiverBalanceBefore; + expect(receiverBalanceChange).to.equal(200n); + }); + }); + + describe("Change balance, one contract", () => { + it("should pass when expected balance change is passed as int and is equal to an actual", async () => { + await expect(async () => + sender.sendTransaction({ + to: contract, + value: 200, + }), + ).to.changeEtherBalance(provider, contract, 200); + }); + + it("should pass when calling function that returns half the sent ether", async () => { + await expect(async () => + contract.returnHalf({ value: 200 }), + ).to.changeEtherBalance(provider, sender, -100); + }); + }); + }); + + describe("Transaction Callback (1559 tx)", () => { + describe("Change balance, one account", () => { + it("should pass when expected balance change is passed as string and is equal to an actual", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-200"); + }); + + it("should pass when expected balance change is passed as int and is equal to an actual", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, receiver, 200); + }); + + it("should take into account transaction fee", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -(txGasFees + 200), { + includeFee: true, + }); + }); + + it("should ignore fee if receiver's wallet is being checked and includeFee was set", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, receiver, 200, { + includeFee: true, + }); + }); + + it("should take into account transaction fee by default", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -200); + }); + + it("should throw when fee was not calculated correctly", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -200, { + includeFee: true, + }), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${ + sender.address + }" to change by -200 wei, but it changed by -${txGasFees + 200} wei`, + ); + }); + + it("should throw when expected balance change value was different from an actual", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-500"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" to change by -500 wei, but it changed by -200 wei`, + ); + }); + + it("should throw in negative case when expected balance change value was equal to an actual", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.not.changeEtherBalance(provider, sender, "-200"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" NOT to change by -200 wei, but it did`, + ); + }); + }); + + describe("Change balance, one contract", () => { + it("should pass when expected balance change is passed as int and is equal to an actual", async () => { + await expect(async () => + sender.sendTransaction({ + to: contract, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, contract, 200); + }); + + it("should take into account transaction fee", async () => { + const tx = { + to: contract, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }; + const gas: bigint = await ethers.provider.estimateGas(tx); + await expect(() => sender.sendTransaction(tx)).to.changeEtherBalance( + provider, + sender, + -(gas + 200n), + { + includeFee: true, + }, + ); + }); + + it("should pass when calling function that returns half the sent ether", async () => { + await expect(async () => + contract.returnHalf({ + value: 200, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + }), + ).to.changeEtherBalance(provider, sender, -100); + }); + }); + + it("shouldn't run the transaction twice", async () => { + const receiverBalanceBefore: bigint = + await ethers.provider.getBalance(receiver); + + await expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -200); + + const receiverBalanceAfter: bigint = + await ethers.provider.getBalance(receiver); + + const receiverBalanceChange = + receiverBalanceAfter - receiverBalanceBefore; + + expect(receiverBalanceChange).to.equal(200n); + }); + }); + + describe("Transaction Response", () => { + describe("Change balance, one account", () => { + it("should pass when expected balance change is passed as string and is equal to an actual", async () => { + await expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-200"); + }); + + it("should pass when expected balance change is passed as int and is equal to an actual", async () => { + await expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, receiver, 200); + }); + + it("should throw when expected balance change value was different from an actual", async () => { + await expect( + expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-500"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" to change by -500 wei, but it changed by -200 wei`, + ); + }); + + it("should throw in negative case when expected balance change value was equal to an actual", async () => { + await expect( + expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalance(provider, sender, "-200"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" NOT to change by -200 wei, but it did`, + ); + }); + }); + + describe("Change balance, one contract", () => { + it("should pass when expected balance change is passed as int and is equal to an actual", async () => { + await expect( + await sender.sendTransaction({ + to: contract, + value: 200, + }), + ).to.changeEtherBalance(provider, contract, 200); + }); + }); + }); + + describe("Transaction Promise", () => { + describe("Change balance, one account", () => { + it("should pass when expected balance change is passed as string and is equal to an actual", async () => { + await expect( + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-200"); + }); + + it("should pass when expected balance change is passed as int and is equal to an actual", async () => { + await expect( + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, receiver, 200); + }); + + it("should throw when expected balance change value was different from an actual", async () => { + await expect( + expect( + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-500"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" to change by -500 wei, but it changed by -200 wei`, + ); + }); + + it("should throw in negative case when expected balance change value was equal to an actual", async () => { + await expect( + expect( + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalance(provider, sender, "-200"), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of "${sender.address}" NOT to change by -200 wei, but it did`, + ); + }); + + it("should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect( + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ) + .to.changeTokenBalance(provider, mockToken, receiver, 0) + .and.to.changeEtherBalance(provider, sender, "-200"), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "changeEtherBalance", + previousMatcher: "changeTokenBalance", + }, + ); + }); + }); + }); + + describe("stack traces", () => { + // smoke test for stack traces + it("includes test file", async () => { + try { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, -100); + } catch (e) { + expect(util.inspect(e)).to.include( + path.join("test", "matchers", "changeEtherBalance.ts"), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/changeEtherBalances.ts b/v-next/hardhat-chai-matchers/test/matchers/changeEtherBalances.ts new file mode 100644 index 0000000000..44dcaa1736 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/changeEtherBalances.ts @@ -0,0 +1,524 @@ +import type { Token } from "../../src/internal/matchers/changeTokenBalance.js"; +import type { ChangeEtherBalance } from "../helpers/contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { + HardhatEthers, + HardhatEthersSigner, +} from "@ignored/hardhat-vnext-ethers/types"; + +import path from "node:path"; +import { before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertThrowsHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { expect, AssertionError } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; +import { initEnvironment } from "../helpers/helpers.js"; + +addChaiMatchers(); + +describe("INTEGRATION: changeEtherBalances matcher", { timeout: 60000 }, () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + let sender: HardhatEthersSigner; + let receiver: HardhatEthersSigner; + let contract: ChangeEtherBalance; + let txGasFees: number; + let mockToken: Token; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + before(async () => { + ({ ethers, provider } = await initEnvironment("change-ether-balances")); + }); + + beforeEach(async () => { + const wallets = await ethers.getSigners(); + sender = wallets[0]; + receiver = wallets[1]; + + contract = await ( + await ethers.getContractFactory<[], ChangeEtherBalance>( + "ChangeEtherBalance", + ) + ).deploy(); + + txGasFees = 1 * 21_000; + + await provider.request({ + method: "hardhat_setNextBlockBaseFeePerGas", + params: ["0x0"], + }); + + const MockToken = await ethers.getContractFactory<[], Token>("MockToken"); + mockToken = await MockToken.deploy(); + }); + + describe("Transaction Callback", () => { + describe("Change balances, one account, one contract", () => { + it("should pass when all expected balance changes are equal to actual values", async () => { + await expect(() => + sender.sendTransaction({ + to: contract, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, contract], [-200, 200]); + }); + }); + + describe("Change balances, contract forwards ether sent", () => { + it("should pass when contract function forwards all tx ether", async () => { + await expect(() => + contract.transferTo(receiver.address, { value: 200 }), + ).to.changeEtherBalances( + provider, + [sender, contract, receiver], + [-200, 0, 200], + ); + }); + }); + + describe("Change balance, multiple accounts", () => { + it("should pass when all expected balance changes are equal to actual values", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], ["-200", 200]); + }); + + it("should pass when given addresses as strings", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender.address, receiver.address], + ["-200", 200], + ); + }); + + it("should pass when given native BigInt", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], [-200n, 200n]); + }); + + it("should pass when given a predicate", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender, receiver], + ([senderDiff, receiverDiff]: bigint[]) => + senderDiff === -200n && receiverDiff === 200n, + ); + }); + + it("should fail when the predicate returns false", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender, receiver], + ([senderDiff, receiverDiff]: bigint[]) => + senderDiff === -201n && receiverDiff === 200n, + ), + ).to.be.eventually.rejectedWith( + AssertionError, + "Expected the balance changes of the accounts to satisfy the predicate, but they didn't", + ); + }); + + it("should fail when the predicate returns true and the assertion is negated", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.not.changeEtherBalances( + provider, + [sender, receiver], + ([senderDiff, receiverDiff]: bigint[]) => + senderDiff === -200n && receiverDiff === 200n, + ), + ).to.be.eventually.rejectedWith( + AssertionError, + "Expected the balance changes of the accounts to NOT satisfy the predicate, but they did", + ); + }); + + it("should take into account transaction fee (legacy tx)", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender, receiver, contract], + [-(txGasFees + 200), 200, 0], + { includeFee: true }, + ); + }); + + it("should take into account transaction fee (1559 tx)", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + maxFeePerGas: 1, + maxPriorityFeePerGas: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender, receiver, contract], + [-(txGasFees + 200), 200, 0], + { includeFee: true }, + ); + }); + + it("should pass when given a single address", async () => { + await expect(() => + sender.sendTransaction({ to: receiver.address, value: 200 }), + ).to.changeEtherBalances(provider, [sender], [-200]); + }); + + it("should pass when negated and numbers don't match", async () => { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.not.changeEtherBalances( + provider, + [sender, receiver], + [-(txGasFees + 201), 200], + ); + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalances( + provider, + [sender, receiver], + [-200, 201], + { + includeFee: true, + }, + ); + }); + + it("should throw when expected balance change value was different from an actual for any wallet", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], [-200, 201]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of ${receiver.address} (the 2nd address in the list) to change by 201 wei, but it changed by 200 wei`, + ); + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], [-201, 200]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of ${sender.address} (the 1st address in the list) to change by -201 wei, but it changed by -200 wei`, + ); + }); + + it("should throw in negative case when expected balance changes value were equal to an actual", async () => { + await expect( + expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.not.changeEtherBalances( + provider, + [sender, receiver], + [-200, 200], + ), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of ${sender.address} (the 1st address in the list) NOT to change by -200 wei`, + ); + }); + + it("arrays have different length", async () => { + expect(() => + expect( + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender], ["-200", 200]), + ).to.throw( + Error, + "The number of accounts (1) is different than the number of expected balance changes (2)", + ); + expect(() => + expect( + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], ["-200"]), + ).to.throw( + Error, + "The number of accounts (2) is different than the number of expected balance changes (1)", + ); + }); + }); + + it("shouldn't run the transaction twice", async () => { + const receiverBalanceBefore = + await ethers.provider.getBalance(receiver); + + await expect(() => + sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], [-200, 200]); + + const receiverBalanceAfter = await ethers.provider.getBalance(receiver); + const receiverBalanceChange = + receiverBalanceAfter - receiverBalanceBefore; + + expect(receiverBalanceChange).to.equal(200n); + }); + }); + + describe("Transaction Response", () => { + describe("Change balances, one account, one contract", () => { + it("should pass when all expected balance changes are equal to actual values", async () => { + await expect( + await sender.sendTransaction({ + to: contract, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, contract], [-200, 200]); + }); + }); + + it("should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect( + sender.sendTransaction({ + to: contract, + value: 200, + }), + ) + .to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [0, 0], + ) + .and.to.changeEtherBalances( + provider, + [sender, contract], + [-200, 200], + ), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "changeEtherBalances", + previousMatcher: "changeTokenBalances", + }, + ); + }); + + describe("Change balance, multiple accounts", () => { + it("should pass when all expected balance changes are equal to actual values", async () => { + await expect( + await sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender, receiver], + [(-(txGasFees + 200)).toString(), 200], + { includeFee: true }, + ); + }); + + it("should take into account transaction fee", async () => { + await expect( + await sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender, receiver, contract], + [-(txGasFees + 200), 200, 0], + { includeFee: true }, + ); + }); + + it("should pass when negated and numbers don't match", async () => { + await expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalances( + provider, + [sender, receiver], + [-201, 200], + ); + await expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalances( + provider, + [sender, receiver], + [-200, 201], + ); + }); + + it("should throw when fee was not calculated correctly", async () => { + await expect( + expect( + await sender.sendTransaction({ + to: receiver.address, + gasPrice: 1, + value: 200, + }), + ).to.changeEtherBalances( + provider, + [sender, receiver], + [-200, 200], + { + includeFee: true, + }, + ), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of ${ + sender.address + } (the 1st address in the list) to change by -200 wei, but it changed by -${ + txGasFees + 200 + } wei`, + ); + }); + + it("should throw when expected balance change value was different from an actual for any wallet", async () => { + await expect( + expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], [-200, 201]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of ${receiver.address} (the 2nd address in the list) to change by 201 wei, but it changed by 200 wei`, + ); + await expect( + expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], [-201, 200]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of ${sender.address} (the 1st address in the list) to change by -201 wei, but it changed by -200 wei`, + ); + }); + + it("should throw in negative case when expected balance changes value were equal to an actual", async () => { + await expect( + expect( + await sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.not.changeEtherBalances( + provider, + [sender, receiver], + [-200, 200], + ), + ).to.be.eventually.rejectedWith( + AssertionError, + `Expected the ether balance of ${sender.address} (the 1st address in the list) NOT to change by -200`, + ); + }); + }); + }); + + describe("stack traces", () => { + // smoke test for stack traces + it("includes test file", async () => { + try { + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalances(provider, [sender, receiver], [-100, 100]); + } catch (e) { + expect(util.inspect(e)).to.include( + path.join("test", "matchers", "changeEtherBalances.ts"), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/changeTokenBalance.ts b/v-next/hardhat-chai-matchers/test/matchers/changeTokenBalance.ts new file mode 100644 index 0000000000..31d80c7bc7 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/changeTokenBalance.ts @@ -0,0 +1,1012 @@ +import type { Token } from "../../src/internal/matchers/changeTokenBalance.js"; +import type { + AnotherContract, + EventsContract, + MatchersContract, +} from "../helpers/contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { + HardhatEthers, + HardhatEthersSigner, +} from "@ignored/hardhat-vnext-ethers/types"; +import type { TransactionResponse } from "ethers"; + +import assert from "node:assert/strict"; +import path from "node:path"; +import { afterEach, before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertThrowsHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; +import { + CHANGE_TOKEN_BALANCE_MATCHER, + CHANGE_TOKEN_BALANCES_MATCHER, +} from "../../src/internal/constants.js"; +import { clearTokenDescriptionsCache } from "../../src/internal/matchers/changeTokenBalance.js"; +import { initEnvironment } from "../helpers/helpers.js"; + +addChaiMatchers(); + +describe( + "INTEGRATION: changeTokenBalance and changeTokenBalances matchers", + { timeout: 60000 }, + () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + afterEach(() => { + clearTokenDescriptionsCache(); + }); + + function runTests() { + let sender: HardhatEthersSigner; + let receiver: HardhatEthersSigner; + let mockToken: Token; + let matchers: MatchersContract; + let otherContract: AnotherContract; + let contract: EventsContract; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + before(async () => { + ({ ethers, provider } = await initEnvironment("change-token-balance")); + }); + + beforeEach(async () => { + const wallets = await ethers.getSigners(); + sender = wallets[0]; + receiver = wallets[1]; + + const MockToken = await ethers.getContractFactory<[], Token>( + "MockToken", + ); + mockToken = await MockToken.deploy(); + + const Matchers = await ethers.getContractFactory<[], MatchersContract>( + "Matchers", + ); + matchers = await Matchers.deploy(); + + otherContract = await ethers.deployContract("AnotherContract"); + contract = await ( + await ethers.getContractFactory<[string], EventsContract>("Events") + ).deploy(await otherContract.getAddress()); + }); + + describe("transaction that doesn't move tokens", () => { + it("with a promise of a TxResponse", async () => { + const transactionResponse = sender.sendTransaction({ + to: receiver.address, + }); + + await runAllAsserts( + provider, + transactionResponse, + mockToken, + [sender, receiver], + [0, 0], + ); + }); + + it("with a TxResponse", async () => { + await runAllAsserts( + provider, + await sender.sendTransaction({ + to: receiver.address, + }), + mockToken, + [sender, receiver], + [0, 0], + ); + }); + + it("with a function that returns a promise of a TxResponse", async () => { + await runAllAsserts( + provider, + () => sender.sendTransaction({ to: receiver.address }), + mockToken, + [sender, receiver], + [0, 0], + ); + }); + + it("with a function that returns a TxResponse", async () => { + const txResponse = await sender.sendTransaction({ + to: receiver.address, + }); + await runAllAsserts( + provider, + () => txResponse, + mockToken, + [sender, receiver], + [0, 0], + ); + }); + + it("accepts addresses", async () => { + await expect( + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalance(provider, mockToken, sender.address, 0); + + await expect(() => + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalances( + provider, + mockToken, + [sender.address, receiver.address], + [0, 0], + ); + + // mixing signers and addresses + await expect(() => + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalances( + provider, + mockToken, + [sender.address, receiver], + [0, 0], + ); + }); + + it("negated", async () => { + await expect( + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalance(provider, mockToken, sender, 1); + + await expect( + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalance( + provider, + mockToken, + sender, + (diff: bigint) => diff > 0n, + ); + + await expect(() => + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [0, 1], + ); + + await expect(() => + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [1, 0], + ); + + await expect(() => + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [1, 1], + ); + }); + + describe("assertion failures", () => { + it("doesn't change balance as expected", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalance(provider, mockToken, sender, 1), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" to change by 1, but it changed by 0/, + ); + }); + + it("change balance doesn't satisfies the predicate", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalance( + provider, + mockToken, + sender, + (diff: bigint) => diff > 0n, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" to satisfy the predicate, but it didn't \(token balance change: 0 wei\)/, + ); + }); + + it("changes balance in the way it was not expected", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalance(provider, mockToken, sender, 0), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" NOT to change by 0, but it did/, + ); + }); + + it("changes balance doesn't have to satisfy the predicate, but it did", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalance( + provider, + mockToken, + sender, + (diff: bigint) => diff < 1n, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" to NOT satisfy the predicate, but it did \(token balance change: 0 wei\)/, + ); + }); + + it("the first account doesn't change its balance as expected", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [1, 0], + ), + ).to.be.rejectedWith(AssertionError); + }); + + it("the second account doesn't change its balance as expected", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [0, 1], + ), + ).to.be.rejectedWith(AssertionError); + }); + + it("neither account changes its balance as expected", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [1, 1], + ), + ).to.be.rejectedWith(AssertionError); + }); + + it("accounts change their balance in the way it was not expected", async () => { + await expect( + expect( + sender.sendTransaction({ to: receiver.address }), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [0, 0], + ), + ).to.be.rejectedWith(AssertionError); + }); + }); + }); + + describe("Transaction Callback", () => { + it("should pass when given predicate", async () => { + await expect(() => + mockToken.transfer(receiver.address, 75), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + ([senderDiff, receiverDiff]: bigint[]) => + senderDiff === -75n && receiverDiff === 75n, + ); + }); + + it("should fail when the predicate returns false", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 75), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + ([senderDiff, receiverDiff]: bigint[]) => + senderDiff === -74n && receiverDiff === 75n, + ), + ).to.be.eventually.rejectedWith( + AssertionError, + "Expected the balance changes of MCK to satisfy the predicate, but they didn't", + ); + }); + + it("should fail when the predicate returns true and the assertion is negated", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 75), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + ([senderDiff, receiverDiff]: bigint[]) => + senderDiff === -75n && receiverDiff === 75n, + ), + ).to.be.eventually.rejectedWith( + AssertionError, + "Expected the balance changes of MCK to NOT satisfy the predicate, but they did", + ); + }); + }); + + describe("transaction that transfers some tokens", () => { + it("with a promise of a TxResponse", async () => { + await runAllAsserts( + provider, + mockToken.transfer(receiver.address, 50), + mockToken, + [sender, receiver], + [-50, 50], + ); + + await runAllAsserts( + provider, + mockToken.transfer(receiver.address, 100), + mockToken, + [sender, receiver], + [-100, 100], + ); + }); + + it("with a TxResponse", async () => { + await runAllAsserts( + provider, + await mockToken.transfer(receiver.address, 150), + mockToken, + [sender, receiver], + [-150, 150], + ); + }); + + it("with a function that returns a promise of a TxResponse", async () => { + await runAllAsserts( + provider, + () => mockToken.transfer(receiver.address, 200), + mockToken, + [sender, receiver], + [-200, 200], + ); + }); + + it("with a function that returns a TxResponse", async () => { + const txResponse = await mockToken.transfer(receiver.address, 300); + await runAllAsserts( + provider, + () => txResponse, + mockToken, + [sender, receiver], + [-300, 300], + ); + }); + + it("changeTokenBalance shouldn't run the transaction twice", async () => { + const receiverBalanceBefore = await mockToken.balanceOf( + receiver.address, + ); + + await expect(() => + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalance(provider, mockToken, receiver, 50); + + const receiverBalanceChange = + (await mockToken.balanceOf(receiver.address)) - + receiverBalanceBefore; + + expect(receiverBalanceChange).to.equal(50n); + }); + + it("changeTokenBalances shouldn't run the transaction twice", async () => { + const receiverBalanceBefore = await mockToken.balanceOf( + receiver.address, + ); + + await expect(() => + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50, 50], + ); + + const receiverBalanceChange = + (await mockToken.balanceOf(receiver.address)) - + receiverBalanceBefore; + + expect(receiverBalanceChange).to.equal(50n); + }); + + it("negated", async () => { + await expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalance(provider, mockToken, sender, 0); + await expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalance(provider, mockToken, sender, 1); + + await expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [0, 0], + ); + await expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50, 0], + ); + await expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [0, 50], + ); + }); + + describe("assertion failures", () => { + it("doesn't change balance as expected", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalance(provider, mockToken, receiver, 500), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" to change by 500, but it changed by 50/, + ); + }); + + it("change balance doesn't satisfies the predicate", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalance( + provider, + mockToken, + receiver, + (diff: bigint) => diff === 500n, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" to satisfy the predicate, but it didn't \(token balance change: 50 wei\)/, + ); + }); + + it("changes balance in the way it was not expected", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalance(provider, mockToken, receiver, 50), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" NOT to change by 50, but it did/, + ); + }); + + it("changes balance doesn't have to satisfy the predicate, but it did", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalance( + provider, + mockToken, + receiver, + (diff: bigint) => diff === 50n, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MCK tokens for "0x\w{40}" to NOT satisfy the predicate, but it did \(token balance change: 50 wei\)/, + ); + }); + + it("the first account doesn't change its balance as expected", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-100, 50], + ), + ).to.be.rejectedWith(AssertionError); + }); + + it("the second account doesn't change its balance as expected", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50, 100], + ), + ).to.be.rejectedWith(AssertionError); + }); + + it("neither account changes its balance as expected", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [0, 0], + ), + ).to.be.rejectedWith(AssertionError); + }); + + it("accounts change their balance in the way it was not expected", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 50), + ).to.not.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50, 50], + ), + ).to.be.rejectedWith(AssertionError); + }); + + it("uses the token name if the contract doesn't have a symbol", async () => { + const TokenWithOnlyName = await ethers.getContractFactory< + [], + Token + >("TokenWithOnlyName"); + + const tokenWithOnlyName = await TokenWithOnlyName.deploy(); + + await expect( + expect( + tokenWithOnlyName.transfer(receiver.address, 50), + ).to.changeTokenBalance( + provider, + tokenWithOnlyName, + receiver, + 500, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MockToken tokens for "0x\w{40}" to change by 500, but it changed by 50/, + ); + + await expect( + expect( + tokenWithOnlyName.transfer(receiver.address, 50), + ).to.not.changeTokenBalance( + provider, + tokenWithOnlyName, + receiver, + 50, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of MockToken tokens for "0x\w{40}" NOT to change by 50, but it did/, + ); + }); + + it("uses the contract address if the contract doesn't have name or symbol", async () => { + const TokenWithoutNameNorSymbol = await ethers.getContractFactory< + [], + Token + >("TokenWithoutNameNorSymbol"); + + const tokenWithoutNameNorSymbol = + await TokenWithoutNameNorSymbol.deploy(); + + await expect( + expect( + tokenWithoutNameNorSymbol.transfer(receiver.address, 50), + ).to.changeTokenBalance( + provider, + tokenWithoutNameNorSymbol, + receiver, + 500, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of tokens for "0x\w{40}" to change by 500, but it changed by 50/, + ); + + await expect( + expect( + tokenWithoutNameNorSymbol.transfer(receiver.address, 50), + ).to.not.changeTokenBalance( + provider, + tokenWithoutNameNorSymbol, + receiver, + 50, + ), + ).to.be.rejectedWith( + AssertionError, + /Expected the balance of tokens for "0x\w{40}" NOT to change by 50, but it did/, + ); + }); + + it("changeTokenBalance: Should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect(contract.emitWithoutArgs()) + .to.emit(contract, "WithoutArgs") + .and.to.changeTokenBalance(provider, mockToken, receiver, 0), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "changeTokenBalance", + previousMatcher: "emit", + }, + ); + }); + + it("changeTokenBalances: should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect(matchers.revertWithCustomErrorWithInt(1)) + .to.be.reverted(ethers) + .and.to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50, 100], + ), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "changeTokenBalances", + previousMatcher: "reverted", + }, + ); + }); + }); + }); + + describe("validation errors", () => { + describe(CHANGE_TOKEN_BALANCE_MATCHER, () => { + it("token is not specified", async () => { + assertThrowsHardhatError( + () => + expect( + mockToken.transfer(receiver.address, 50), + // @ts-expect-error -- force error scenario: token should be specified + ).to.changeTokenBalance(provider, receiver, 50), + HardhatError.ERRORS.CHAI_MATCHERS + .FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, + { + method: CHANGE_TOKEN_BALANCE_MATCHER, + }, + ); + + // if an address is used (receiver.address) + assertThrowsHardhatError( + () => + expect( + mockToken.transfer(receiver.address, 50), + // @ts-expect-error -- force error scenario: token should be specified + ).to.changeTokenBalance(provider, receiver.address, 50), + HardhatError.ERRORS.CHAI_MATCHERS + .FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, + { + method: CHANGE_TOKEN_BALANCE_MATCHER, + }, + ); + }); + + it("contract is not a token", async () => { + const NotAToken = await ethers.getContractFactory("NotAToken"); + const notAToken = await NotAToken.deploy(); + + expect(() => + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalance(provider, notAToken, sender, -50), + ).to.throw( + Error, + "The given contract instance is not an ERC20 token", + ); + }); + + it("tx is not the only one in the block", async () => { + await provider.request({ + method: "evm_setAutomine", + params: [false], + }); + + // we set a gas limit to avoid using the whole block gas limit + await sender.sendTransaction({ + to: receiver.address, + gasLimit: 30_000, + }); + + await provider.request({ + method: "evm_setAutomine", + params: [true], + }); + + await expect( + expect( + mockToken.transfer(receiver.address, 50, { gasLimit: 100_000 }), + ).to.changeTokenBalance(provider, mockToken, sender, -50), + ).to.be.rejectedWith( + "There should be only 1 transaction in the block", + ); + }); + + it("tx reverts", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 0), + ).to.changeTokenBalance(provider, mockToken, sender, -50), + ).to.be.rejectedWith( + Error, + // check that the error message includes the revert reason + "Transferred value is zero", + ); + }); + }); + + describe(CHANGE_TOKEN_BALANCES_MATCHER, () => { + it("token is not specified", async () => { + assertThrowsHardhatError( + () => + expect( + mockToken.transfer(receiver.address, 50), + // @ts-expect-error -- force error scenario: token should be specified + ).to.changeTokenBalances( + provider, + [sender, receiver], + [-50, 50], + ), + HardhatError.ERRORS.CHAI_MATCHERS + .FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, + { + method: CHANGE_TOKEN_BALANCES_MATCHER, + }, + ); + }); + + it("contract is not a token", async () => { + const NotAToken = await ethers.getContractFactory("NotAToken"); + const notAToken = await NotAToken.deploy(); + + expect(() => + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + notAToken, + [sender, receiver], + [-50, 50], + ), + ).to.throw( + Error, + "The given contract instance is not an ERC20 token", + ); + }); + it("arrays have different length", async () => { + expect(() => + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender], + [-50, 50], + ), + ).to.throw( + Error, + "The number of accounts (1) is different than the number of expected balance changes (2)", + ); + + expect(() => + expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50], + ), + ).to.throw( + Error, + "The number of accounts (2) is different than the number of expected balance changes (1)", + ); + }); + + it("arrays have different length, subject is a rejected promise", async () => { + expect(() => + expect(matchers.revertsWithoutReason()).to.changeTokenBalances( + provider, + mockToken, + [sender], + [-50, 50], + ), + ).to.throw( + Error, + "The number of accounts (1) is different than the number of expected balance changes (2)", + ); + }); + + it("tx is not the only one in the block", async () => { + await provider.request({ + method: "evm_setAutomine", + params: [false], + }); + + // we set a gas limit to avoid using the whole block gas limit + await sender.sendTransaction({ + to: receiver.address, + gasLimit: 30_000, + }); + + await provider.request({ + method: "evm_setAutomine", + params: [true], + }); + + await expect( + expect( + mockToken.transfer(receiver.address, 50, { gasLimit: 100_000 }), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50, 50], + ), + ).to.be.rejectedWith( + "There should be only 1 transaction in the block", + ); + }); + + it("tx reverts", async () => { + await expect( + expect( + mockToken.transfer(receiver.address, 0), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50, 50], + ), + ).to.be.rejectedWith( + Error, + // check that the error message includes the revert reason + "Transferred value is zero", + ); + }); + }); + }); + + describe("accepted number types", () => { + it("native bigints are accepted", async () => { + await expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalance(provider, mockToken, sender, -50n); + await expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-50n, 50n], + ); + }); + }); + + // smoke tests for stack traces + describe("stack traces", () => { + describe(CHANGE_TOKEN_BALANCE_MATCHER, () => { + it("includes test file", async () => { + let hasProperStackTrace = false; + try { + await expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalance(provider, mockToken, sender, -100); + } catch (e) { + hasProperStackTrace = util + .inspect(e) + .includes( + path.join("test", "matchers", "changeTokenBalance.ts"), + ); + } + expect(hasProperStackTrace).to.equal(true); + }); + }); + + describe(CHANGE_TOKEN_BALANCES_MATCHER, () => { + it("includes test file", async () => { + try { + await expect( + mockToken.transfer(receiver.address, 50), + ).to.changeTokenBalances( + provider, + mockToken, + [sender, receiver], + [-100, 100], + ); + } catch (e) { + expect(util.inspect(e)).to.include( + path.join("test", "matchers", "changeTokenBalance.ts"), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + }); + } + }, +); + +function zip(a: T[], b: U[]): Array<[T, U]> { + assert(a.length === b.length, "lengths should match"); + + return a.map((x, i) => [x, b[i]]); +} + +/** + * Given an expression `expr`, a token, and a pair of arrays, check that + * `changeTokenBalance` and `changeTokenBalances` behave correctly in different + * scenarios. + */ +async function runAllAsserts( + provider: EthereumProvider, + expr: + | TransactionResponse + | Promise + | (() => TransactionResponse) + | (() => Promise), + token: Token, + accounts: Array, + balances: Array, +) { + // changeTokenBalances works for the given arrays + await expect(expr).to.changeTokenBalances( + provider, + token, + accounts, + balances, + ); + + // changeTokenBalances works for empty arrays + await expect(expr).to.changeTokenBalances(provider, token, [], []); + + // for each given pair of account and balance, check that changeTokenBalance + // works correctly + for (const [account, balance] of zip(accounts, balances)) { + await expect(expr).to.changeTokenBalance(provider, token, account, balance); + } +} diff --git a/v-next/hardhat-chai-matchers/test/matchers/events.ts b/v-next/hardhat-chai-matchers/test/matchers/events.ts new file mode 100644 index 0000000000..7a82459d0f --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/events.ts @@ -0,0 +1,921 @@ +import type { + AnotherContract, + EventsContract, + MatchersContract, + OverrideEventContract, +} from "../helpers/contracts.js"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +import { before, beforeEach, describe, it } from "node:test"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertRejectsWithHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { expect, AssertionError } from "chai"; +import { id } from "ethers/hash"; +import { hexlify, toUtf8Bytes, zeroPadValue } from "ethers/utils"; +import { Wallet } from "ethers/wallet"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; +import { anyUint, anyValue } from "../../src/withArgs.js"; +import { initEnvironment } from "../helpers/helpers.js"; + +addChaiMatchers(); + +describe(".to.emit (contract events)", { timeout: 60000 }, () => { + let contract: EventsContract; + let otherContract: AnotherContract; + let overrideEventContract: OverrideEventContract; + let matchers: MatchersContract; + + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + let ethers: HardhatEthers; + + before(async () => { + ({ ethers } = await initEnvironment("events")); + }); + + beforeEach(async () => { + otherContract = await ethers.deployContract("AnotherContract"); + + contract = await ( + await ethers.getContractFactory<[string], EventsContract>("Events") + ).deploy(await otherContract.getAddress()); + + overrideEventContract = await ( + await ethers.getContractFactory<[], OverrideEventContract>( + "OverrideEventContract", + ) + ).deploy(); + + const Matchers = await ethers.getContractFactory<[], MatchersContract>( + "Matchers", + ); + matchers = await Matchers.deploy(); + }); + + it("should fail when expecting an event that's not in the contract", async () => { + await expect( + expect(contract.doNotEmit()).to.emit(contract, "NonexistentEvent"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Event "NonexistentEvent" doesn\'t exist in the contract', + ); + }); + + it("should fail when expecting an event that's not in the contract to NOT be emitted", async () => { + await expect( + expect(contract.doNotEmit()).not.to.emit(contract, "NonexistentEvent"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Event "NonexistentEvent" doesn\'t exist in the contract', + ); + }); + + it("should fail when matcher is called with too many arguments", async () => { + await assertRejectsWithHardhatError( + () => + // @ts-expect-error -- force error scenario: emit should not be called with more than two arguments + expect(contract.emitUint(1)).not.to.emit(contract, "WithoutArgs", 1), + HardhatError.ERRORS.CHAI_MATCHERS.EMIT_EXPECTS_TWO_ARGUMENTS, + {}, + ); + }); + + it("should detect events without arguments", async () => { + await expect(contract.emitWithoutArgs()).to.emit(contract, "WithoutArgs"); + }); + + it("should fail when expecting an event that wasn't emitted", async () => { + await expect( + expect(contract.doNotEmit()).to.emit(contract, "WithoutArgs"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Expected event "WithoutArgs" to be emitted, but it wasn\'t', + ); + }); + + it("should fail when expecting a specific event NOT to be emitted but it WAS", async () => { + await expect( + expect(contract.emitWithoutArgs()).to.not.emit(contract, "WithoutArgs"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Expected event "WithoutArgs" NOT to be emitted, but it was', + ); + }); + + describe(".withArgs", () => { + it("should fail when used with .not.", async () => { + expect(() => + expect(contract.emitUint(1)) + .not.to.emit(contract, "WithUintArg") + .withArgs(1), + ).to.throw(Error, "Do not combine .not. with .withArgs()"); + }); + + it("should fail when used with .not, subject is a rejected promise", async () => { + expect(() => + expect(matchers.revertsWithoutReason()) + .not.to.emit(contract, "WithUintArg") + .withArgs(1), + ).to.throw(Error, "Do not combine .not. with .withArgs()"); + }); + + it("should fail if withArgs is called on its own", async () => { + expect(() => + expect(contract.emitUint(1)) + // @ts-expect-error -- force "withArgs" to be called on its own + .withArgs(1), + ).to.throw( + Error, + "withArgs can only be used in combination with a previous .emit or .revertedWithCustomError assertion", + ); + }); + + it("should verify zero arguments", async () => { + await expect(contract.emitWithoutArgs()) + .to.emit(contract, "WithoutArgs") + .withArgs(); + }); + + describe("with a uint argument", () => { + it("should match the argument", async () => { + await expect(contract.emitUint(1)) + .to.emit(contract, "WithUintArg") + .withArgs(1); + }); + + it("should fail when the input argument doesn't match the event argument", async () => { + await expect( + expect(contract.emitUint(1)) + .to.emit(contract, "WithUintArg") + .withArgs(2), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithUintArg" event: Error in the 1st argument assertion: expected 1 to equal 2.', + ); + }); + + it("should fail when too many arguments are given", async () => { + await expect( + expect(contract.emitUint(1)) + .to.emit(contract, "WithUintArg") + .withArgs(1, 3), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithUintArg" event: Expected arguments array to have length 2, but it has 1', + ); + }); + }); + + describe("with an address argument", () => { + const addressable = Wallet.createRandom(); + const { address } = addressable; + const otherAddressable = Wallet.createRandom(); + const { address: otherAddress } = otherAddressable; + + it("should match the argument", async () => { + await expect(contract.emitAddress(addressable)) + .to.emit(contract, "WithAddressArg") + .withArgs(address); + }); + + it("should match addressable arguments", async () => { + await expect(contract.emitAddress(addressable)) + .to.emit(contract, "WithAddressArg") + .withArgs(addressable); + }); + + it("should fail when the input argument doesn't match the addressable event argument", async () => { + await expect( + expect(contract.emitAddress(addressable)) + .to.emit(contract, "WithAddressArg") + .withArgs(otherAddressable), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithAddressArg" event: Error in the 1st argument assertion: expected '${address}' to equal '${otherAddress}'`, + ); + }); + + it("should fail when the input argument doesn't match the address event argument", async () => { + await expect( + expect(contract.emitAddress(addressable)) + .to.emit(contract, "WithAddressArg") + .withArgs(otherAddress), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithAddressArg" event: Error in the 1st argument assertion: expected '${address}' to equal '${otherAddress}'`, + ); + }); + + it("should fail when too many arguments are given", async () => { + await expect( + expect(contract.emitAddress(addressable)) + .to.emit(contract, "WithAddressArg") + .withArgs(address, otherAddress), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithAddressArg" event: Expected arguments array to have length 2, but it has 1', + ); + }); + }); + + // for abbreviating long strings in diff views like chai does: + function abbrev(longString: string): string { + return `${longString.substring(0, 37)}…`; + } + + function formatHash(str: string, hashFn = id) { + const hash = hashFn(str); + return { + str, + hash, + abbrev: abbrev(hash), + }; + } + + function formatBytes(str: string) { + const bytes = hexlify(toUtf8Bytes(str)); + const bytes32 = zeroPadValue(bytes, 32); + return { + ...formatHash(str), + bytes, + bytes32, + abbrev32: abbrev(hexlify(bytes32)), + }; + } + + const str1 = formatBytes("string1"); + const str2 = formatBytes("string2"); + + describe("with a string argument", () => { + it("should match the argument", async () => { + await expect(contract.emitString("string")) + .to.emit(contract, "WithStringArg") + .withArgs("string"); + }); + + it("should fail when the input argument doesn't match the event argument", async () => { + await expect( + expect(contract.emitString(str1.str)) + .to.emit(contract, "WithStringArg") + .withArgs(str2.str), + ).to.be.eventually.rejectedWith( + AssertionError, + `expected '${str1.str}' to equal '${str2.str}'`, + ); + }); + }); + + describe("with an indexed string argument", () => { + it("should match the argument", async () => { + await expect(contract.emitIndexedString(str1.str)) + .to.emit(contract, "WithIndexedStringArg") + .withArgs(str1.str); + }); + + it("should fail when the input argument doesn't match the event argument", async () => { + await expect( + expect(contract.emitIndexedString(str1.str)) + .to.emit(contract, "WithIndexedStringArg") + .withArgs(str2.str), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithIndexedStringArg" event: Error in the 1st argument assertion: The actual value was an indexed and hashed value of the event argument. The expected value provided to the assertion was hashed to produce ${str2.hash}. The actual hash and the expected hash ${str1.hash} did not match: expected '${str1.abbrev}' to equal '${str2.abbrev}'`, + ); + }); + + it("should fail if expected argument is the hash not the pre-image", async () => { + await expect( + expect(contract.emitIndexedString(str1.str)) + .to.emit(contract, "WithIndexedStringArg") + .withArgs(str1.hash), + ).to.be.eventually.rejectedWith( + AssertionError, + "The actual value was an indexed and hashed value of the event argument. The expected value provided to the assertion should be the actual event argument (the pre-image of the hash). You provided the hash itself. Please supply the actual event argument (the pre-image of the hash) instead", + ); + }); + + it("should fail when trying to match the event argument with an incorrect hash value", async () => { + const incorrect = formatHash(str2.hash, ethers.keccak256); + await expect( + expect(contract.emitIndexedString(str1.str)) + .to.emit(contract, "WithIndexedStringArg") + .withArgs(incorrect.str), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithIndexedStringArg" event: Error in the 1st argument assertion: The actual value was an indexed and hashed value of the event argument. The expected value provided to the assertion was hashed to produce ${incorrect.hash}. The actual hash and the expected hash ${str1.hash} did not match: expected '${str1.abbrev}' to equal '${incorrect.abbrev}`, + ); + }); + }); + + describe("with a bytes argument", () => { + it("should match the argument", async () => { + await expect(contract.emitBytes(str1.bytes)) + .to.emit(contract, "WithBytesArg") + .withArgs(str1.bytes); + }); + + it("should fail when the input argument doesn't match the event argument", async () => { + await expect( + expect(contract.emitBytes(str2.bytes)) + .to.emit(contract, "WithBytesArg") + .withArgs(str1.str), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithBytesArg" event: Error in the 1st argument assertion: expected '${str2.bytes}' to equal '${str1.str}'`, + ); + }); + }); + + describe("with an indexed bytes argument", () => { + it("should match the argument", async () => { + await expect(contract.emitIndexedBytes(str1.bytes)) + .to.emit(contract, "WithIndexedBytesArg") + .withArgs(str1.str); + }); + + it("should fail when the input argument doesn't match the event argument", async () => { + await expect( + expect(contract.emitIndexedBytes(str2.bytes)) + .to.emit(contract, "WithIndexedBytesArg") + .withArgs(str1.str), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithIndexedBytesArg" event: Error in the 1st argument assertion: The actual value was an indexed and hashed value of the event argument. The expected value provided to the assertion was hashed to produce ${str1.hash}. The actual hash and the expected hash ${str2.hash} did not match: expected '${str2.abbrev}' to equal '${str1.abbrev}'`, + ); + }); + + it("should fail the passerd argument is the hash, not the pre-image", async () => { + await expect( + expect(contract.emitIndexedBytes(str1.bytes)) + .to.emit(contract, "WithIndexedBytesArg") + .withArgs(str1.hash), + ).to.be.eventually.rejectedWith( + AssertionError, + "The actual value was an indexed and hashed value of the event argument. The expected value provided to the assertion should be the actual event argument (the pre-image of the hash). You provided the hash itself. Please supply the actual event argument (the pre-image of the hash) instead.", + ); + }); + }); + + describe("with a bytes32 argument", () => { + it("should match the argument", async () => { + await expect(contract.emitBytes32(str1.bytes32)) + .to.emit(contract, "WithBytes32Arg") + .withArgs(str1.bytes32); + }); + + it("should fail when the input argument doesn't match the event argument", async () => { + await expect( + expect(contract.emitBytes32(str2.bytes32)) + .to.emit(contract, "WithBytes32Arg") + .withArgs(str1.bytes32), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithBytes32Arg" event: Error in the 1st argument assertion: expected '${str2.abbrev32}' to equal '${str1.abbrev32}'`, + ); + }); + }); + + describe("with an indexed bytes32 argument", () => { + it("should match the argument", async () => { + await expect(contract.emitIndexedBytes32(str1.bytes32)) + .to.emit(contract, "WithIndexedBytes32Arg") + .withArgs(str1.bytes32); + }); + + it("should fail when the input argument doesn't match the event argument", async () => { + await expect( + expect(contract.emitIndexedBytes32(str2.bytes32)) + .to.emit(contract, "WithIndexedBytes32Arg") + .withArgs(str1.bytes32), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithIndexedBytes32Arg" event: Error in the 1st argument assertion: expected '${str2.abbrev32}' to equal '${str1.abbrev32}'`, + ); + }); + }); + + describe("with a uint array argument", () => { + it("should succeed when expectations are met", async () => { + await expect(contract.emitUintArray(1, 2)) + .to.emit(contract, "WithUintArray") + .withArgs([1, 2]); + }); + + it("should fail when expectations are not met", async () => { + await expect( + expect(contract.emitUintArray(1, 2)) + .to.emit(contract, "WithUintArray") + .withArgs([3, 4]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithUintArray" event: Error in the 1st argument assertion: Error in the 1st argument assertion: expected 1 to equal 3.`, + ); + }); + + describe("nested predicate", () => { + it("should succeed when predicate passes", async () => { + await expect(contract.emitUintArray(1, 2)) + .to.emit(contract, "WithUintArray") + .withArgs([anyValue, 2]); + }); + + it("should fail when predicate returns false", async () => { + await expect( + expect(contract.emitUintArray(1, 2)) + .to.emit(contract, "WithUintArray") + .withArgs([() => false, 4]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithUintArray" event: Error in the 1st argument assertion: Error in the 1st argument assertion: The predicate did not return true`, + ); + }); + + it("should fail when predicate reverts", async () => { + await expect( + expect(contract.emitUintArray(1, 2)) + .to.emit(contract, "WithUintArray") + .withArgs([ + () => { + throw new Error("user error"); + }, + 4, + ]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithUintArray" event: Error in the 1st argument assertion: Error in the 1st argument assertion: The predicate threw when called: user error`, + ); + }); + }); + + describe("arrays different length", () => { + it("should fail when the array is shorter", async () => { + await expect( + expect(contract.emitUintArray(1, 2)) + .to.emit(contract, "WithUintArray") + .withArgs([1]), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithUintArray" event: Error in the 1st argument assertion: Expected arguments array to have length 1, but it has 2', + ); + }); + + it("should fail when the array is longer", async () => { + await expect( + expect(contract.emitUintArray(1, 2)) + .to.emit(contract, "WithUintArray") + .withArgs([1, 2, 3]), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithUintArray" event: Error in the 1st argument assertion: Expected arguments array to have length 3, but it has 2', + ); + }); + }); + }); + + describe("with a bytes32 array argument", () => { + const aa = `0x${"aa".repeat(32)}`; + const bb = `0x${"bb".repeat(32)}`; + const cc = `0x${"cc".repeat(32)}`; + const dd = `0x${"dd".repeat(32)}`; + + it("should succeed when expectations are met", async () => { + await expect(contract.emitBytes32Array(aa, bb)) + .to.emit(contract, "WithBytes32Array") + .withArgs([aa, bb]); + }); + + it("should fail when expectations are not met", async () => { + await expect( + expect(contract.emitBytes32Array(aa, bb)) + .to.emit(contract, "WithBytes32Array") + .withArgs([cc, dd]), + ).to.be.eventually.rejectedWith( + AssertionError, + `Error in "WithBytes32Array" event: Error in the 1st argument assertion: Error in the 1st argument assertion: expected '${abbrev( + aa, + )}' to equal '${abbrev(cc)}'`, + ); + }); + }); + + describe("with a struct argument", () => { + it("should succeed when expectations are met", async () => { + await expect(contract.emitStruct(1, 2)) + .to.emit(contract, "WithStructArg") + .withArgs([1, 2]); + }); + + it("should fail when expectations are not met", async () => { + await expect( + expect(contract.emitStruct(1, 2)) + .to.emit(contract, "WithStructArg") + .withArgs([3, 4]), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithStructArg" event: Error in the 1st argument assertion: Error in the 1st argument assertion: expected 1 to equal 3.', + ); + }); + }); + + describe("with multiple arguments", () => { + it("should successfully match the arguments", async () => { + await expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(1, 2); + }); + + it("should fail when the first argument isn't matched", async () => { + await expect( + expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(2, 2), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithTwoUintArgs" event: Error in the 1st argument assertion: expected 1 to equal 2', + ); + }); + + it("should fail when the second argument isn't matched", async () => { + await expect( + expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(1, 1), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithTwoUintArgs" event: Error in the 2nd argument assertion: expected 2 to equal 1.', + ); + }); + + it("should fail when too many arguments are supplied", async () => { + await expect( + expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(1, 2, 3, 4), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithTwoUintArgs" event: Expected arguments array to have length 4, but it has 2', + ); + }); + + it("should fail when too few arguments are supplied", async () => { + await expect( + expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(1), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithTwoUintArgs" event: Expected arguments array to have length 1, but it has 2', + ); + }); + + describe("should handle argument predicates", () => { + it("should pass when a predicate argument returns true", async () => { + await expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(anyValue, anyUint); + }); + + it("should fail when a predicate argument returns false", async () => { + await expect( + expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(1, () => false), + ).to.be.rejectedWith( + AssertionError, + 'Error in "WithTwoUintArgs" event: Error in the 2nd argument assertion: The predicate did not return true', + ); + }); + + it("should fail when a predicate argument throws an error", async () => { + await expect( + expect(contract.emitTwoUints(1, 2)) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(() => { + throw new Error("user-defined error"); + }, "foo"), + ).to.be.rejectedWith( + Error, + 'Error in "WithTwoUintArgs" event: Error in the 1st argument assertion: The predicate threw when called: user-defined error', + ); + }); + + describe("with predicate anyUint", () => { + it("should fail when the event argument is a string", async () => { + await expect( + expect(contract.emitString("a string")) + .to.emit(contract, "WithStringArg") + .withArgs(anyUint), + ).to.be.rejectedWith( + AssertionError, + "Error in \"WithStringArg\" event: Error in the 1st argument assertion: The predicate threw when called: anyUint expected its argument to be an integer, but its type was 'string'", + ); + }); + + it("should fail when the event argument is negative", async () => { + await expect( + expect(contract.emitInt(-1)) + .to.emit(contract, "WithIntArg") + .withArgs(anyUint), + ).to.be.rejectedWith( + AssertionError, + 'Error in "WithIntArg" event: Error in the 1st argument assertion: The predicate threw when called: anyUint expected its argument to be an unsigned integer, but it was negative, with value -1', + ); + }); + }); + }); + }); + }); + + describe("With one call that emits two separate events", () => { + it("should successfully catch each event independently", async () => { + await expect(contract.emitUintAndString(1, "a string")).to.emit( + contract, + "WithUintArg", + ); + await expect(contract.emitUintAndString(1, "a string")).to.emit( + contract, + "WithStringArg", + ); + }); + + describe("When detecting two events from one call (chaining)", () => { + it("should succeed when both expected events are indeed emitted", async () => { + await expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .and.to.emit(contract, "WithStringArg"); + }); + + it("should succeed when the expected event is emitted and the unexpected event is not", async () => { + await expect(contract.emitWithoutArgs()) + .to.emit(contract, "WithoutArgs") + .and.not.to.emit(otherContract, "WithUintArg"); + }); + + describe("When one of the expected events is emitted and the other is not", () => { + it("should fail when the first expected event is emitted but the second is not", async () => { + await expect( + expect(contract.emitUint(1)) + .to.emit(contract, "WithUintArg") + .and.to.emit(contract, "WithStringArg"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Expected event "WithStringArg" to be emitted, but it wasn\'t', + ); + }); + + it("should fail when the second expected event is emitted but the first is not", async () => { + await expect( + expect(contract.emitUint(1)) + .to.emit(contract, "WithStringArg") + .and.to.emit(contract, "WithUintArg"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Expected event "WithStringArg" to be emitted, but it wasn\'t', + ); + }); + }); + + describe("When specifying .withArgs()", () => { + it("should pass when expecting the correct args from the first event", async () => { + await expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .withArgs(1) + .and.to.emit(contract, "WithStringArg"); + }); + + it("should pass when expecting the correct args from the second event", async () => { + await expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .and.to.emit(contract, "WithStringArg") + .withArgs("a string"); + }); + + it("should pass when expecting the correct args from both events", async () => { + await expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .withArgs(1) + .and.to.emit(contract, "WithStringArg") + .withArgs("a string"); + }); + + it("should fail when expecting the wrong argument value for the first event", async () => { + await expect( + expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .withArgs(2) + .and.to.emit(contract, "WithStringArg"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithUintArg" event: Error in the 1st argument assertion: expected 1 to equal 2.', + ); + }); + + it("should fail when expecting the wrong argument value for the second event", async () => { + await expect( + expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .and.to.emit(contract, "WithStringArg") + .withArgs("a different string"), + ).to.be.eventually.rejectedWith( + AssertionError, + "Error in \"WithStringArg\" event: Error in the 1st argument assertion: expected 'a string' to equal 'a different string'", + ); + }); + + it("should fail when expecting too many arguments from the first event", async () => { + await expect( + expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .withArgs(1, 2) + .and.to.emit(contract, "WithStringArg"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithUintArg" event: Expected arguments array to have length 2, but it has 1', + ); + }); + + it("should fail when expecting too many arguments from the second event", async () => { + await expect( + expect(contract.emitUintAndString(1, "a string")) + .to.emit(contract, "WithUintArg") + .and.to.emit(contract, "WithStringArg") + .withArgs("a different string", "yet another string"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithStringArg" event: Expected arguments array to have length 2, but it has 1', + ); + }); + + it("should fail when expecting too few arguments from the first event", async () => { + await expect( + expect( + contract.emitTwoUintsAndTwoStrings( + 1, + 2, + "a string", + "another string", + ), + ) + .to.emit(contract, "WithTwoUintArgs") + .withArgs(1) + .and.to.emit(contract, "WithTwoStringArgs"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithTwoUintArgs" event: Expected arguments array to have length 1, but it has 2', + ); + }); + + it("should fail when expecting too few arguments from the second event", async () => { + await expect( + expect( + contract.emitTwoUintsAndTwoStrings( + 1, + 2, + "a string", + "another string", + ), + ) + .to.emit(contract, "WithTwoUintArgs") + .and.to.emit(contract, "WithTwoStringArgs") + .withArgs("a string"), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Error in "WithTwoStringArgs" event: Expected arguments array to have length 1, but it has 2', + ); + }); + }); + + describe("With a contract that emits the same event twice but with different arguments", () => { + it("should pass when expectations are met", async () => { + await expect(contract.emitUintTwice(1, 2)) + .to.emit(contract, "WithUintArg") + .withArgs(1) + .and.to.emit(contract, "WithUintArg") + .withArgs(2); + }); + + it("should fail when the first event's argument is not matched", async () => { + await expect( + expect(contract.emitUintTwice(1, 2)) + .to.emit(contract, "WithUintArg") + .withArgs(3) + .and.to.emit(contract, "WithUintArg") + .withArgs(2), + ).to.be.eventually.rejectedWith( + AssertionError, + 'The specified arguments ([ 3 ]) were not included in any of the 2 emitted "WithUintArg" events', + ); + }); + + it("should fail when the second event's argument is not matched", async () => { + await expect( + expect(contract.emitUintTwice(1, 2)) + .to.emit(contract, "WithUintArg") + .withArgs(1) + .and.to.emit(contract, "WithUintArg") + .withArgs(3), + ).to.be.eventually.rejectedWith( + AssertionError, + 'The specified arguments ([ 3 ]) were not included in any of the 2 emitted "WithUintArg" events', + ); + }); + + it("should fail when none of the emitted events match the given argument", async () => { + await expect( + expect(contract.emitUintTwice(1, 2)) + .to.emit(contract, "WithUintArg") + .withArgs(3), + ).to.be.eventually.rejectedWith( + AssertionError, + 'The specified arguments ([ 3 ]) were not included in any of the 2 emitted "WithUintArg" events', + ); + }); + }); + }); + }); + + describe("When nested events are emitted", () => { + describe("With the nested event emitted from the same contract", () => { + it("should pass when the expected event is emitted", async () => { + await expect(contract.emitNestedUintFromSameContract(1)) + .to.emit(contract, "WithUintArg") + .withArgs(1); + }); + + it("should fail when the expected event is not emitted", async () => { + await expect( + expect(contract.emitNestedUintFromSameContract(1)).to.emit( + contract, + "WithStringArg", + ), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Expected event "WithStringArg" to be emitted, but it wasn\'t', + ); + }); + }); + + describe("With the nested event emitted from a different contract", () => { + it("should pass when the expected event is emitted", async () => { + await expect(contract.emitNestedUintFromAnotherContract(1)) + .to.emit(otherContract, "WithUintArg") + .withArgs(1); + }); + + it("should fail when the expected event is emitted but not by the contract that was passed", async () => { + await expect( + expect(contract.emitNestedUintFromAnotherContract(1)) + .to.emit(contract, "WithUintArg") + .withArgs(1), + ).to.be.eventually.rejectedWith( + AssertionError, + 'Expected event "WithUintArg" to be emitted, but it wasn\'t', + ); + }); + }); + }); + + it("With executed transaction", async () => { + const tx = await contract.emitWithoutArgs(); + await expect(tx).to.emit(contract, "WithoutArgs"); + }); + + it("With transaction hash", async () => { + const tx = await contract.emitWithoutArgs(); + await expect(tx.hash).to.emit(contract, "WithoutArgs"); + }); + + describe("When event is overloaded", () => { + it("should fail when the event name is ambiguous", async () => { + await expect( + expect(overrideEventContract.emitSimpleEventWithUintArg(1n)).to.emit( + overrideEventContract, + "simpleEvent", + ), + ).to.be.eventually.rejectedWith( + AssertionError, + `ambiguous event description (i.e. matches "simpleEvent(uint256)", "simpleEvent()")`, + ); + }); + + it("should pass when the event name is not ambiguous", async () => { + await expect(overrideEventContract.emitSimpleEventWithUintArg(1n)) + .to.emit(overrideEventContract, "simpleEvent(uint256)") + .withArgs(1); + await expect(overrideEventContract.emitSimpleEventWithoutArg()).to.emit( + overrideEventContract, + "simpleEvent()", + ); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/hexEqual.ts b/v-next/hardhat-chai-matchers/test/matchers/hexEqual.ts new file mode 100644 index 0000000000..3d14b21ece --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/hexEqual.ts @@ -0,0 +1,83 @@ +import { describe, it } from "node:test"; + +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; + +addChaiMatchers(); + +describe("UNIT: hexEqual", () => { + it("0xAB equals 0xab", () => { + expect("0xAB").to.hexEqual("0xab"); + }); + + it("0xAB does not equal 0xabc", () => { + expect("0xAB").to.not.hexEqual("0xabc"); + }); + + it("0x0010ab equals 0x000010ab", () => { + expect("0x0010ab").to.hexEqual("0x000010ab"); + }); + + it("0x0000010AB does not equal 0x0010abc", () => { + expect("0x0000010AB").to.not.hexEqual("0x0010abc"); + }); + + it("0x edge case", () => { + expect("0x").to.hexEqual("0x000000"); + }); + + it("abc is not a hex string", () => { + expect(() => expect("abc").to.hexEqual("0xabc")).to.throw( + AssertionError, + 'Expected "abc" to be a hex string equal to "0xabc", but "abc" is not a valid hex string', + ); + expect(() => expect("0xabc").to.hexEqual("abc")).to.throw( + AssertionError, + 'Expected "0xabc" to be a hex string equal to "abc", but "abc" is not a valid hex string', + ); + expect(() => expect("abc").to.not.hexEqual("0xabc")).to.throw( + AssertionError, + 'Expected "abc" not to be a hex string equal to "0xabc", but "abc" is not a valid hex string', + ); + expect(() => expect("0xabc").to.not.hexEqual("abc")).to.throw( + AssertionError, + 'Expected "0xabc" not to be a hex string equal to "abc", but "abc" is not a valid hex string', + ); + }); + + it("xyz is not a hex string", () => { + expect(() => expect("xyz").to.hexEqual("0x1A4")).to.throw( + AssertionError, + 'Expected "xyz" to be a hex string equal to "0x1A4", but "xyz" is not a valid hex string', + ); + }); + + it("0xyz is not a hex string", () => { + expect(() => expect("0xyz").to.hexEqual("0x1A4")).to.throw( + AssertionError, + 'Expected "0xyz" to be a hex string equal to "0x1A4", but "0xyz" is not a valid hex string', + ); + }); + + it("empty string is not a hex string", () => { + expect(() => expect("").to.hexEqual("0x0")).to.throw( + AssertionError, + 'Expected "" to be a hex string equal to "0x0", but "" is not a valid hex string', + ); + }); + + it("correct error when strings are not equal", async () => { + expect(() => expect("0xa").to.hexEqual("0xb")).to.throw( + AssertionError, + 'Expected "0xa" to be a hex string equal to "0xb"', + ); + }); + + it("correct error when strings are equal but expected not to", async () => { + expect(() => expect("0xa").not.to.hexEqual("0xa")).to.throw( + AssertionError, + 'Expected "0xa" NOT to be a hex string equal to "0xa", but it was', + ); + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/panic.ts b/v-next/hardhat-chai-matchers/test/matchers/panic.ts new file mode 100644 index 0000000000..1fa08f6e99 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/panic.ts @@ -0,0 +1,18 @@ +import { describe, it } from "node:test"; + +import { assert } from "chai"; +import { toBigInt } from "ethers"; + +import { + PANIC_CODES, + panicErrorCodeToReason, +} from "../../src/internal/matchers/reverted/panic.js"; + +describe("panic codes", () => { + it("all exported panic codes should have a description", async () => { + for (const [key, code] of Object.entries(PANIC_CODES)) { + const description = panicErrorCodeToReason(toBigInt(code)); + assert.isDefined(description, `No description for panic code ${key}`); + } + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/properAddress.ts b/v-next/hardhat-chai-matchers/test/matchers/properAddress.ts new file mode 100644 index 0000000000..ba5cb40701 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/properAddress.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions -- allow all the "expect" to be expressions */ + +import { describe, it } from "node:test"; + +import { expect, AssertionError } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; + +addChaiMatchers(); + +describe("Proper address", () => { + it("Expect to be proper address", async () => { + expect("0x28FAA621c3348823D6c6548981a19716bcDc740e").to.be.properAddress; + expect("0x846C66cf71C43f80403B51fE3906B3599D63336f").to.be.properAddress; + }); + + it("Expect not to be proper address", async () => { + expect("28FAA621c3348823D6c6548981a19716bcDc740e").not.to.be.properAddress; + expect("0x28FAA621c3348823D6c6548981a19716bcDc740").to.not.be.properAddress; + expect("0x846C66cf71C43f80403B51fE3906B3599D63336g").to.not.be + .properAddress; + expect("0x846C66cf71C43f80403B51fE3906B3599D6333-f").to.not.be + .properAddress; + }); + + it("Expect to throw if invalid address", async () => { + expect( + () => + expect("0x28FAA621c3348823D6c6548981a19716bcDc740").to.be.properAddress, + ).to.throw( + AssertionError, + 'Expected "0x28FAA621c3348823D6c6548981a19716bcDc740" to be a proper address', + ); + }); + + it("Expect to throw if negation with proper address)", async () => { + expect( + () => + expect("0x28FAA621c3348823D6c6548981a19716bcDc740e").not.to.be + .properAddress, + ).to.throw( + AssertionError, + 'Expected "0x28FAA621c3348823D6c6548981a19716bcDc740e" NOT to be a proper address', + ); + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/properHex.ts b/v-next/hardhat-chai-matchers/test/matchers/properHex.ts new file mode 100644 index 0000000000..f9edea853d --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/properHex.ts @@ -0,0 +1,58 @@ +import { describe, it } from "node:test"; + +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; + +addChaiMatchers(); + +describe("properHex", () => { + it("should handle a successful positive case", () => { + expect("0xAB").to.be.properHex(2); + }); + + it("should handle a successful negative case", () => { + expect("0xab").to.not.be.properHex(3); + }); + + it("should handle a positive case failing because of an invalid length", () => { + const input = "0xABCDEF"; + const length = 99; + expect(() => expect(input).to.be.properHex(length)).to.throw( + AssertionError, + `Expected "${input}" to be a hex string of length ${ + length + 2 + } (the provided ${length} plus 2 more for the "0x" prefix), but its length is ${ + input.length + }`, + ); + }); + + it("should handle a positive case failing because of an invalid hex value", () => { + expect(() => expect("0xABCDEFG").to.be.properHex(8)).to.throw( + AssertionError, + 'Expected "0xABCDEFG" to be a proper hex string, but it contains invalid (non-hex) characters', + ); + }); + + it("should handle a negative case failing because of a valid length", () => { + const input = "0xab"; + const length = 2; + expect(() => expect(input).to.not.be.properHex(length)).to.throw( + AssertionError, + `Expected "${input}" NOT to be a hex string of length ${ + length + 2 + } (the provided ${length} plus 2 more for the "0x" prefix), but its length is ${ + input.length + }`, + ); + }); + + it("should handle a negative case failing because of an invalid hex value", () => { + const input = "0xabcdefg"; + expect(() => expect(input).to.not.be.properHex(8)).to.throw( + AssertionError, + `Expected "${input}" NOT to be a proper hex string, but it contains only valid hex characters`, + ); + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/properPrivateKey.ts b/v-next/hardhat-chai-matchers/test/matchers/properPrivateKey.ts new file mode 100644 index 0000000000..763e653ca0 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/properPrivateKey.ts @@ -0,0 +1,51 @@ +import { describe, it } from "node:test"; + +import { expect, AssertionError } from "chai"; + +import { addChaiMatchers } from "../../src/internal/add-chai-matchers.js"; + +/* eslint-disable @typescript-eslint/no-unused-expressions -- allow all the expressions */ + +addChaiMatchers(); + +describe("Proper private key", () => { + it("Expect to be proper private key", async () => { + expect("0x706618637b8ca922f6290ce1ecd4c31247e9ab75cf0530a0ac95c0332173d7c5") + .to.be.properPrivateKey; + expect("0x03c909455dcef4e1e981a21ffb14c1c51214906ce19e8e7541921b758221b5ae") + .to.be.properPrivateKey; + }); + + it("Expect not to be proper private key", async () => { + expect("0x28FAA621c3348823D6c6548981a19716bcDc740").to.not.be + .properPrivateKey; + expect("0x706618637b8ca922f6290ce1ecd4c31247e9ab75cf0530a0ac95c0332173d7cw") + .to.not.be.properPrivateKey; + expect("0x03c909455dcef4e1e981a21ffb14c1c51214906ce19e8e7541921b758221b5-e") + .to.not.be.properPrivateKey; + }); + + it("Expect to throw if invalid private key", async () => { + expect( + () => + expect( + "0x706618637b8ca922f6290ce1ecd4c31247e9ab75cf0530a0ac95c0332173d7c", + ).to.be.properPrivateKey, + ).to.throw( + AssertionError, + 'Expected "0x706618637b8ca922f6290ce1ecd4c31247e9ab75cf0530a0ac95c0332173d7c" to be a proper private key', + ); + }); + + it("Expect to throw if negation with proper private key)", async () => { + expect( + () => + expect( + "0x706618637b8ca922f6290ce1ecd4c31247e9ab75cf0530a0ac95c0332173d7c5", + ).not.to.be.properPrivateKey, + ).to.throw( + AssertionError, + 'Expected "0x706618637b8ca922f6290ce1ecd4c31247e9ab75cf0530a0ac95c0332173d7c5" NOT to be a proper private key', + ); + }); +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/reverted/reverted.ts b/v-next/hardhat-chai-matchers/test/matchers/reverted/reverted.ts new file mode 100644 index 0000000000..14353d864a --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/reverted/reverted.ts @@ -0,0 +1,471 @@ +import type { MatchersContract } from "../../helpers/contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +import path from "node:path"; +import { before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertRejectsWithHardhatError, + assertThrowsHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../../src/internal/add-chai-matchers.js"; +import { + runSuccessfulAsserts, + runFailedAsserts, + mineSuccessfulTransaction, + mineRevertedTransaction, + initEnvironment, +} from "../../helpers/helpers.js"; + +addChaiMatchers(); + +describe("INTEGRATION: Reverted", { timeout: 60000 }, () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + // deploy Matchers contract before each test + let matchers: MatchersContract; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + before(async () => { + ({ ethers, provider } = await initEnvironment("reverted")); + }); + + beforeEach(async () => { + const Matchers = await ethers.getContractFactory<[], MatchersContract>( + "Matchers", + ); + matchers = await Matchers.deploy(); + }); + + // helpers + const expectAssertionError = async (x: Promise, message: string) => { + return expect(x).to.be.eventually.rejectedWith(AssertionError, message); + }; + + describe("with a string as its subject", () => { + it("hash of a successful transaction", async () => { + const { hash } = await mineSuccessfulTransaction(provider, ethers); + + await expectAssertionError( + expect(hash).to.be.reverted(ethers), + "Expected transaction to be reverted", + ); + await expect(hash).to.not.be.reverted(ethers); + }); + + it("hash of a reverted transaction", async () => { + const { hash } = await mineRevertedTransaction( + provider, + ethers, + matchers, + ); + + await expect(hash).to.be.reverted(ethers); + await expectAssertionError( + expect(hash).to.not.be.reverted(ethers), + "Expected transaction NOT to be reverted", + ); + }); + + it("invalid string", async () => { + await assertRejectsWithHardhatError( + () => expect("0x123").to.be.reverted(ethers), + HardhatError.ERRORS.CHAI_MATCHERS.EXPECTED_VALID_TRANSACTION_HASH, + { + hash: "0x123", + }, + ); + + await assertRejectsWithHardhatError( + () => expect("0x123").to.not.be.reverted(ethers), + HardhatError.ERRORS.CHAI_MATCHERS.EXPECTED_VALID_TRANSACTION_HASH, + { + hash: "0x123", + }, + ); + }); + + it("promise of a hash of a successful transaction", async () => { + const { hash } = await mineSuccessfulTransaction(provider, ethers); + await expectAssertionError( + expect(Promise.resolve(hash)).to.be.reverted(ethers), + "Expected transaction to be reverted", + ); + await expect(Promise.resolve(hash)).to.not.be.reverted(ethers); + }); + + it("promise of a hash of a reverted transaction", async () => { + const { hash } = await mineRevertedTransaction( + provider, + ethers, + matchers, + ); + await expect(Promise.resolve(hash)).to.be.reverted(ethers); + await expectAssertionError( + expect(Promise.resolve(hash)).to.not.be.reverted(ethers), + "Expected transaction NOT to be reverted", + ); + }); + + it("promise of an invalid string", async () => { + await assertRejectsWithHardhatError( + () => expect(Promise.resolve("0x123")).to.be.reverted(ethers), + HardhatError.ERRORS.CHAI_MATCHERS.EXPECTED_VALID_TRANSACTION_HASH, + { + hash: "0x123", + }, + ); + + await assertRejectsWithHardhatError( + () => expect(Promise.resolve("0x123")).to.not.be.reverted(ethers), + HardhatError.ERRORS.CHAI_MATCHERS.EXPECTED_VALID_TRANSACTION_HASH, + { + hash: "0x123", + }, + ); + }); + + it("promise of an byte32 string", async () => { + await expect( + Promise.resolve( + "0x3230323400000000000000000000000000000000000000000000000000000000", + ), + ).not.to.be.reverted(ethers); + }); + }); + + describe("with a TxResponse as its subject", () => { + it("TxResponse of a successful transaction", async () => { + const tx = await mineSuccessfulTransaction(provider, ethers); + + await expectAssertionError( + expect(tx).to.be.reverted(ethers), + "Expected transaction to be reverted", + ); + await expect(tx).to.not.be.reverted(ethers); + }); + + it("TxResponse of a reverted transaction", async () => { + const tx = await mineRevertedTransaction(provider, ethers, matchers); + + await expect(tx).to.be.reverted(ethers); + await expectAssertionError( + expect(tx).to.not.be.reverted(ethers), + "Expected transaction NOT to be reverted", + ); + }); + + it("promise of a TxResponse of a successful transaction", async () => { + const txPromise = mineSuccessfulTransaction(provider, ethers); + + await expectAssertionError( + expect(txPromise).to.be.reverted(ethers), + "Expected transaction to be reverted", + ); + await expect(txPromise).to.not.be.reverted(ethers); + }); + + it("promise of a TxResponse of a reverted transaction", async () => { + const txPromise = mineRevertedTransaction(provider, ethers, matchers); + + await expect(txPromise).to.be.reverted(ethers); + await expectAssertionError( + expect(txPromise).to.not.be.reverted(ethers), + "Expected transaction NOT to be reverted", + ); + }); + + it("reverted: should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect(matchers.revertsWith("bar")) + .to.be.revertedWith("bar") + .and.to.be.reverted(ethers), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "reverted", + previousMatcher: "revertedWith", + }, + ); + }); + + it("revertedWith: should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect(matchers.revertWithCustomErrorWithInt(1)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithInt") + .and.to.be.revertedWith("an error message"), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "revertedWith", + previousMatcher: "revertedWithCustomError", + }, + ); + }); + + it("revertedWithCustomError: should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect(matchers.revertsWithoutReason()) + .to.be.revertedWithoutReason(ethers) + .and.to.be.revertedWithCustomError(matchers, "SomeCustomError"), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "revertedWithCustomError", + previousMatcher: "revertedWithoutReason", + }, + ); + }); + + it("revertedWithoutReason: should throw if chained to another non-chainable method", () => { + assertThrowsHardhatError( + () => + expect(matchers.panicAssert()) + .to.be.revertedWithPanic() + .and.to.be.revertedWithoutReason(ethers), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "revertedWithoutReason", + previousMatcher: "revertedWithPanic", + }, + ); + }); + + it("revertedWithPanic: should throw if chained to another non-chainable method", async () => { + const [sender, receiver] = await ethers.getSigners(); + + assertThrowsHardhatError( + () => + expect(() => + sender.sendTransaction({ + to: receiver, + value: 200, + }), + ) + .to.changeEtherBalance(provider, sender, "-200") + .and.to.be.revertedWithPanic(), + HardhatError.ERRORS.CHAI_MATCHERS.MATCHER_CANNOT_BE_CHAINED_AFTER, + { + matcher: "revertedWithPanic", + previousMatcher: "changeEtherBalance", + }, + ); + }); + }); + + describe("with a TxReceipt as its subject", () => { + it("TxReceipt of a successful transaction", async () => { + const tx = await mineSuccessfulTransaction(provider, ethers); + const receipt = await tx.wait(); + + await expectAssertionError( + expect(receipt).to.be.reverted(ethers), + "Expected transaction to be reverted", + ); + await expect(receipt).to.not.be.reverted(ethers); + }); + + it("TxReceipt of a reverted transaction", async () => { + const tx = await mineRevertedTransaction(provider, ethers, matchers); + const receipt = await ethers.provider.getTransactionReceipt(tx.hash); // tx.wait rejects, so we use provider.getTransactionReceipt + + await expect(receipt).to.be.reverted(ethers); + await expectAssertionError( + expect(receipt).to.not.be.reverted(ethers), + "Expected transaction NOT to be reverted", + ); + }); + + it("promise of a TxReceipt of a successful transaction", async () => { + const tx = await mineSuccessfulTransaction(provider, ethers); + const receiptPromise = tx.wait(); + + await expectAssertionError( + expect(receiptPromise).to.be.reverted(ethers), + "Expected transaction to be reverted", + ); + await expect(receiptPromise).to.not.be.reverted(ethers); + }); + + it("promise of a TxReceipt of a reverted transaction", async () => { + const tx = await mineRevertedTransaction(provider, ethers, matchers); + const receiptPromise = ethers.provider.getTransactionReceipt(tx.hash); // tx.wait rejects, so we use provider.getTransactionReceipt + + await expect(receiptPromise).to.be.reverted(ethers); + await expectAssertionError( + expect(receiptPromise).to.not.be.reverted(ethers), + "Expected transaction NOT to be reverted", + ); + }); + }); + + describe("calling a contract method that succeeds", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + args: [], + successfulAssert: (x) => expect(x).to.not.be.reverted(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "succeeds", + args: [], + failedAssert: (x) => expect(x).to.be.reverted(ethers), + failedAssertReason: "Expected transaction to be reverted", + }); + }); + }); + + describe("calling a method that reverts without a reason", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWithoutReason", + args: [], + successfulAssert: (x) => expect(x).to.be.reverted(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWithoutReason", + args: [], + failedAssert: (x) => expect(x).not.to.be.reverted(ethers), + failedAssertReason: "Expected transaction NOT to be reverted", + }); + }); + }); + + describe("calling a method that reverts with a reason string", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + successfulAssert: (x) => expect(x).to.be.reverted(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + failedAssert: (x) => expect(x).not.to.be.reverted(ethers), + failedAssertReason: + "Expected transaction NOT to be reverted, but it reverted with reason 'some reason'", + }); + }); + }); + + describe("calling a method that reverts with a panic code", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "panicAssert", + args: [], + successfulAssert: (x) => expect(x).to.be.reverted(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "panicAssert", + args: [], + failedAssert: (x) => expect(x).not.to.be.reverted(ethers), + failedAssertReason: + "Expected transaction NOT to be reverted, but it reverted with panic code 0x1 (Assertion error)", + }); + }); + }); + + describe("calling a method that reverts with a custom error", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertWithSomeCustomError", + args: [], + successfulAssert: (x) => expect(x).to.be.reverted(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertWithSomeCustomError", + args: [], + failedAssert: (x) => expect(x).not.to.be.reverted(ethers), + failedAssertReason: "Expected transaction NOT to be reverted", + }); + }); + }); + + describe("invalid rejection values", () => { + it("non-errors", async () => { + await expectAssertionError( + expect(Promise.reject({})).to.be.reverted(ethers), + "Expected an Error object", + ); + }); + + it("errors that are not related to a reverted transaction", async () => { + // use an address that almost surely doesn't have balance + const randomPrivateKey = + "0xc5c587cc6e48e9692aee0bf07474118e6d830c11905f7ec7ff32c09c99eba5f9"; + const signer = new ethers.Wallet(randomPrivateKey, ethers.provider); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the contract is of type MatchersContract + const matchersFromSenderWithoutFunds = matchers.connect( + signer, + ) as MatchersContract; + + // this transaction will fail because of lack of funds, not because of a + // revert + await expect( + expect( + matchersFromSenderWithoutFunds.revertsWithoutReason({ + gasLimit: 1_000_000, + }), + ).to.not.be.reverted(ethers), + ).to.be.eventually.rejectedWith( + /^Sender doesn't have enough funds to send tx\. The max upfront cost is: (\d+) and the sender's balance is: (\d+)\.$/, + ); + }); + }); + + describe("stack traces", () => { + // smoke test for stack traces + it("includes test file", async () => { + try { + await expect(matchers.succeeds()).to.be.reverted(ethers); + } catch (e) { + const errorString = util.inspect(e); + expect(errorString).to.include("Expected transaction to be reverted"); + expect(errorString).to.include( + path.join("test", "matchers", "reverted", "reverted.ts"), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWith.ts b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWith.ts new file mode 100644 index 0000000000..404c34d265 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWith.ts @@ -0,0 +1,268 @@ +import type { MatchersContract } from "../../helpers/contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +import path from "node:path"; +import { before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertThrowsHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../../src/internal/add-chai-matchers.js"; +import { + runSuccessfulAsserts, + runFailedAsserts, + mineSuccessfulTransaction, + initEnvironment, +} from "../../helpers/helpers.js"; + +addChaiMatchers(); + +describe("INTEGRATION: Reverted with", { timeout: 60000 }, () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + // deploy Matchers contract before each test + let matchers: MatchersContract; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + before(async () => { + ({ ethers, provider } = await initEnvironment("reverted-with")); + }); + + beforeEach(async () => { + const Matchers = await ethers.getContractFactory<[], MatchersContract>( + "Matchers", + ); + matchers = await Matchers.deploy(); + }); + + describe("calling a method that succeeds", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => + expect(x).not.to.be.revertedWith("some reason"), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "succeeds", + failedAssert: (x) => expect(x).to.be.revertedWith("some reason"), + failedAssertReason: + "Expected transaction to be reverted with reason 'some reason', but it didn't revert", + }); + }); + }); + + describe("calling a method that reverts without a reason", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWithoutReason", + successfulAssert: (x) => + expect(x).to.not.be.revertedWith("some reason"), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWithoutReason", + failedAssert: (x) => expect(x).to.be.revertedWith("some reason"), + failedAssertReason: + "Expected transaction to be reverted with reason 'some reason', but it reverted without a reason", + }); + }); + }); + + describe("calling a method that reverts with a reason string", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + successfulAssert: (x) => expect(x).to.be.revertedWith("some reason"), + }); + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["regular expression reason"], + successfulAssert: (x) => + expect(x).to.be.revertedWith(/regular .* reason/), + }); + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + successfulAssert: (x) => + expect(x).to.not.be.revertedWith("another reason"), + }); + }); + + it("failed asserts: expected reason not to match", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + failedAssert: (x) => expect(x).to.not.be.revertedWith("some reason"), + failedAssertReason: + "Expected transaction NOT to be reverted with reason 'some reason', but it was", + }); + }); + + it("failed asserts: expected a different reason", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["another reason"], + failedAssert: (x) => expect(x).to.be.revertedWith("some reason"), + failedAssertReason: + "Expected transaction to be reverted with reason 'some reason', but it reverted with reason 'another reason'", + }); + }); + + it("failed asserts: expected a different regular expression reason ", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["another regular expression reason"], + failedAssert: (x) => + expect(x).to.be.revertedWith(/some regular .* reason/), + failedAssertReason: + "Expected transaction to be reverted with reason 'some regular .* reason', but it reverted with reason 'another regular expression reason'", + }); + }); + }); + + describe("calling a method that reverts with a panic code", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "panicAssert", + successfulAssert: (x) => + expect(x).to.not.be.revertedWith("some reason"), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "panicAssert", + failedAssert: (x) => expect(x).to.be.revertedWith("some reason"), + failedAssertReason: + "Expected transaction to be reverted with reason 'some reason', but it reverted with panic code 0x1 (Assertion error)", + }); + }); + }); + + describe("calling a method that reverts with a custom error", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertWithSomeCustomError", + successfulAssert: (x) => + expect(x).to.not.be.revertedWith("some reason"), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertWithSomeCustomError", + failedAssert: (x) => expect(x).to.be.revertedWith("some reason"), + failedAssertReason: + "Expected transaction to be reverted with reason 'some reason', but it reverted with a custom error", + }); + }); + }); + + describe("invalid values", () => { + it("non-errors as subject", async () => { + await expect( + expect(Promise.reject({})).to.be.revertedWith("some reason"), + ).to.be.rejectedWith(AssertionError, "Expected an Error object"); + }); + + it("non-string as expectation", async () => { + const { hash } = await mineSuccessfulTransaction(provider, ethers); + + assertThrowsHardhatError( + // @ts-expect-error -- force error scenario: reason should be a string or a regular expression + () => expect(hash).to.be.revertedWith(10), + HardhatError.ERRORS.CHAI_MATCHERS + .EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, + {}, + ); + }); + + it("non-string as expectation, subject is a rejected promise", async () => { + const tx = matchers.revertsWithoutReason(); + + assertThrowsHardhatError( + // @ts-expect-error -- force error scenario: reason should be a string or a regular expression + () => expect(tx).to.be.revertedWith(10), + HardhatError.ERRORS.CHAI_MATCHERS + .EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, + {}, + ); + }); + + it("errors that are not related to a reverted transaction", async () => { + // use an address that almost surely doesn't have balance + const randomPrivateKey = + "0xc5c587cc6e48e9692aee0bf07474118e6d830c11905f7ec7ff32c09c99eba5f9"; + const signer = new ethers.Wallet(randomPrivateKey, ethers.provider); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the contract is of type MatchersContract + const matchersFromSenderWithoutFunds = matchers.connect( + signer, + ) as MatchersContract; + + // this transaction will fail because of lack of funds, not because of a + // revert + await expect( + expect( + matchersFromSenderWithoutFunds.revertsWithoutReason({ + gasLimit: 1_000_000, + }), + ).to.not.be.revertedWith("some reason"), + ).to.be.eventually.rejectedWith( + /^Sender doesn't have enough funds to send tx\. The max upfront cost is: (\d+) and the sender's balance is: (\d+)\.$/, + ); + }); + }); + + describe("stack traces", () => { + // smoke test for stack traces + it("includes test file", async () => { + try { + await expect(matchers.revertsWith("bar")).to.be.revertedWith("foo"); + } catch (e) { + const errorString = util.inspect(e); + expect(errorString).to.include( + "Expected transaction to be reverted with reason 'foo', but it reverted with reason 'bar'", + ); + expect(errorString).to.include( + path.join("test", "matchers", "reverted", "revertedWith.ts"), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithCustomError.ts b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithCustomError.ts new file mode 100644 index 0000000000..76dfc76f6d --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithCustomError.ts @@ -0,0 +1,561 @@ +import type { MatchersContract } from "../../helpers/contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +import path from "node:path"; +import { before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertThrowsHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../../src/internal/add-chai-matchers.js"; +import { anyUint, anyValue } from "../../../src/withArgs.js"; +import { + runSuccessfulAsserts, + runFailedAsserts, + mineSuccessfulTransaction, + initEnvironment, +} from "../../helpers/helpers.js"; + +addChaiMatchers(); + +describe("INTEGRATION: Reverted with custom error", { timeout: 60000 }, () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + // deploy Matchers contract before each test + let matchers: MatchersContract; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + before(async () => { + ({ ethers, provider } = await initEnvironment( + "reverted-with-custom-error", + )); + }); + + beforeEach(async () => { + const Matchers = await ethers.getContractFactory<[], MatchersContract>( + "Matchers", + ); + + matchers = await Matchers.deploy(); + }); + + describe("calling a method that succeeds", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => + expect(x).not.to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "succeeds", + failedAssert: (x) => + expect(x).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + failedAssertReason: + "Expected transaction to be reverted with custom error 'SomeCustomError', but it didn't revert", + }); + }); + }); + + describe("calling a method that reverts without a reason", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWithoutReason", + successfulAssert: (x) => + expect(x).to.not.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWithoutReason", + failedAssert: (x) => + expect(x).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + failedAssertReason: + "Expected transaction to be reverted with custom error 'SomeCustomError', but it reverted without a reason", + }); + }); + }); + + describe("calling a method that reverts with a reason string", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + successfulAssert: (x) => + expect(x).to.not.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + failedAssert: (x) => + expect(x).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + failedAssertReason: + "Expected transaction to be reverted with custom error 'SomeCustomError', but it reverted with reason 'some reason'", + }); + }); + }); + + describe("calling a method that reverts with a panic code", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "panicAssert", + successfulAssert: (x) => + expect(x).to.not.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "panicAssert", + failedAssert: (x) => + expect(x).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + failedAssertReason: + "Expected transaction to be reverted with custom error 'SomeCustomError', but it reverted with panic code 0x1 (Assertion error)", + }); + }); + }); + + describe("calling a method that reverts with a custom error", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertWithSomeCustomError", + successfulAssert: (x) => + expect(x).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + }); + + await runSuccessfulAsserts({ + matchers, + method: "revertWithAnotherCustomError", + successfulAssert: (x) => + expect(x).to.not.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + }); + }); + + it("failed asserts: expected custom error not to match", async () => { + await runFailedAsserts({ + matchers, + method: "revertWithSomeCustomError", + failedAssert: (x) => + expect(x).to.not.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + failedAssertReason: + "Expected transaction NOT to be reverted with custom error 'SomeCustomError', but it was", + }); + }); + + it("failed asserts: reverts with another custom error of the same contract", async () => { + await runFailedAsserts({ + matchers, + method: "revertWithAnotherCustomError", + failedAssert: (x) => + expect(x).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + failedAssertReason: + "Expected transaction to be reverted with custom error 'SomeCustomError', but it reverted with custom error 'AnotherCustomError'", + }); + }); + + it("failed asserts: reverts with another custom error of another contract", async () => { + await runFailedAsserts({ + matchers, + method: "revertWithAnotherContractCustomError", + failedAssert: (x) => + expect(x).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + failedAssertReason: + "Expected transaction to be reverted with custom error 'SomeCustomError', but it reverted with a different custom error", + }); + }); + }); + + describe("with args", () => { + describe("one argument", () => { + it("should match correct argument", async () => { + await expect(matchers.revertWithCustomErrorWithUint(1)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithUint") + .withArgs(1); + }); + + it("should fail if wrong argument", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithUint(1)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithUint") + .withArgs(2), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithUint" custom error: Error in the 1st argument assertion: expected 1 to equal 2.', + ); + }); + }); + + describe("two arguments", () => { + it("should match correct values", async () => { + await expect( + matchers.revertWithCustomErrorWithUintAndString(1, "foo"), + ) + .to.be.revertedWithCustomError( + matchers, + "CustomErrorWithUintAndString", + ) + .withArgs(1, "foo"); + }); + + it("should fail if uint is wrong", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithUintAndString(1, "foo")) + .to.be.revertedWithCustomError( + matchers, + "CustomErrorWithUintAndString", + ) + .withArgs(2, "foo"), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithUintAndString" custom error: Error in the 1st argument assertion: expected 1 to equal 2.', + ); + }); + + it("should fail if string is wrong", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithUintAndString(1, "foo")) + .to.be.revertedWithCustomError( + matchers, + "CustomErrorWithUintAndString", + ) + .withArgs(1, "bar"), + ).to.be.rejectedWith( + AssertionError, + "Error in \"CustomErrorWithUintAndString\" custom error: Error in the 2nd argument assertion: expected 'foo' to equal 'bar'", + ); + }); + + it("should fail if first predicate throws", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithUintAndString(1, "foo")) + .to.be.revertedWithCustomError( + matchers, + "CustomErrorWithUintAndString", + ) + .withArgs(() => { + throw new Error("user-defined error"); + }, "foo"), + ).to.be.rejectedWith( + Error, + 'Error in "CustomErrorWithUintAndString" custom error: Error in the 1st argument assertion: The predicate threw when called: user-defined error', + ); + }); + }); + + describe("different number of arguments", () => { + it("should reject if expected fewer arguments", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithUintAndString(1, "s")) + .to.be.revertedWithCustomError( + matchers, + "CustomErrorWithUintAndString", + ) + .withArgs(1), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithUintAndString" custom error: Expected arguments array to have length 1, but it has 2', + ); + }); + + it("should reject if expected more arguments", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithUintAndString(1, "s")) + .to.be.revertedWithCustomError( + matchers, + "CustomErrorWithUintAndString", + ) + .withArgs(1, "s", 3), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithUintAndString" custom error: Expected arguments array to have length 3, but it has 2', + ); + }); + }); + + describe("nested arguments", () => { + it("should match correct arguments", async () => { + await expect(matchers.revertWithCustomErrorWithPair(1, 2)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithPair") + .withArgs([1, 2]); + }); + + it("should reject different arguments", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithPair(1, 2)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithPair") + .withArgs([3, 2]), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithPair" custom error: Error in the 1st argument assertion: Error in the 1st argument assertion: expected 1 to equal 3.', + ); + }); + }); + + describe("array of different lengths", () => { + it("should fail if the expected array is bigger", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithPair(1, 2)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithPair") + .withArgs([1]), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithPair" custom error: Error in the 1st argument assertion: Expected arguments array to have length 1, but it has 2', + ); + }); + + it("should fail if the expected array is smaller", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithPair(1, 2)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithPair") + .withArgs([1, 2, 3]), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithPair" custom error: Error in the 1st argument assertion: Expected arguments array to have length 3, but it has 2', + ); + }); + }); + + it("should fail when used with .not.", async () => { + expect(() => + expect(matchers.revertWithSomeCustomError()) + .to.not.be.revertedWithCustomError(matchers, "SomeCustomError") + .withArgs(1), + ).to.throw(Error, "Do not combine .not. with .withArgs()"); + }); + + it("should fail if withArgs is called on its own", async () => { + expect(() => + // @ts-expect-error -- force "withArgs" to be called on its own + expect(matchers.revertWithCustomErrorWithUint(1)).withArgs(1), + ).to.throw( + Error, + "withArgs can only be used in combination with a previous .emit or .revertedWithCustomError assertion", + ); + }); + + // See https://github.com/NomicFoundation/hardhat/issues/4235 + // it.skip("should fail if both emit and revertedWithCustomError are called", async () => { + // expect(() => + // expect(matchers.revertWithSomeCustomError()) + // .to.emit(matchers, "SomeEvent") + // .and.to.be.revertedWithCustomError(matchers, "SomeCustomError") + // .withArgs(1), + // ).to.throw( + // Error, + // "withArgs called with both .emit and .revertedWithCustomError, but these assertions cannot be combined", + // ); + // }); + + describe("should handle argument predicates", () => { + it("should pass when a predicate argument returns true", async () => { + await expect(matchers.revertWithCustomErrorWithUint(1)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithUint") + .withArgs(anyValue); + await expect(matchers.revertWithCustomErrorWithUint(1)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithUint") + .withArgs(anyUint); + }); + + it("should fail when a predicate argument returns false", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithUint(1)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithUint") + .withArgs(() => false), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithUint" custom error: Error in the 1st argument assertion: The predicate did not return true', + ); + }); + + it("should fail when a predicate argument throws an error", async () => { + await expect( + expect(matchers.revertWithCustomErrorWithInt(-1)) + .to.be.revertedWithCustomError(matchers, "CustomErrorWithInt") + .withArgs(anyUint), + ).to.be.rejectedWith( + AssertionError, + 'Error in "CustomErrorWithInt" custom error: Error in the 1st argument assertion: The predicate threw when called: anyUint expected its argument to be an unsigned integer, but it was negative, with value -1', + ); + }); + }); + }); + + describe("invalid values", () => { + it("non-errors as subject", async () => { + await expect( + expect(Promise.reject({})).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + ), + ).to.be.rejectedWith(AssertionError, "Expected an Error object"); + }); + + it("non-string as expectation", async () => { + const { hash } = await mineSuccessfulTransaction(provider, ethers); + + assertThrowsHardhatError( + // @ts-expect-error -- force error scenario: reason should be a string or a regular expression + () => expect(hash).to.be.revertedWith(10), + HardhatError.ERRORS.CHAI_MATCHERS + .EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, + {}, + ); + }); + + it("the contract is not specified", async () => { + assertThrowsHardhatError( + () => + expect( + matchers.revertWithSomeCustomError(), + // @ts-expect-error -- force error scenario: contract should be specified + ).to.be.revertedWithCustomError("SomeCustomError"), + HardhatError.ERRORS.CHAI_MATCHERS.FIRST_ARGUMENT_MUST_BE_A_CONTRACT, + {}, + ); + }); + + it("the contract doesn't have a custom error with that name", async () => { + assertThrowsHardhatError( + () => + expect( + matchers.revertWithSomeCustomError(), + ).to.be.revertedWithCustomError(matchers, "SomeCustmError"), + HardhatError.ERRORS.CHAI_MATCHERS.CONTRACT_DOES_NOT_HAVE_CUSTOM_ERROR, + { customErrorName: "SomeCustmError" }, + ); + }); + + it("errors that are not related to a reverted transaction", async () => { + // use an address that almost surely doesn't have balance + const randomPrivateKey = + "0xc5c587cc6e48e9692aee0bf07474118e6d830c11905f7ec7ff32c09c99eba5f9"; + const signer = new ethers.Wallet(randomPrivateKey, ethers.provider); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the contract is of type MatchersContract + const matchersFromSenderWithoutFunds = matchers.connect( + signer, + ) as MatchersContract; + + // this transaction will fail because of lack of funds, not because of a + // revert + await expect( + expect( + matchersFromSenderWithoutFunds.revertsWithoutReason({ + gasLimit: 1_000_000, + }), + ).to.not.be.revertedWithCustomError(matchers, "SomeCustomError"), + ).to.be.eventually.rejectedWith( + "Sender doesn't have enough funds to send tx", + ); + }); + + it("extra arguments", async () => { + assertThrowsHardhatError( + () => + expect( + matchers.revertWithSomeCustomError(), + ).to.be.revertedWithCustomError( + matchers, + "SomeCustomError", + // @ts-expect-error -- force error scenario: extra arguments should not be specified + "extraArgument", + ), + HardhatError.ERRORS.CHAI_MATCHERS.REVERT_INVALID_ARGUMENTS_LENGTH, + {}, + ); + }); + }); + + describe("stack traces", () => { + // smoke test for stack traces + it("includes test file", async () => { + try { + await expect( + matchers.revertsWith("some reason"), + ).to.be.revertedWithCustomError(matchers, "SomeCustomError"); + } catch (e) { + const errorString = util.inspect(e); + expect(errorString).to.include( + "Expected transaction to be reverted with custom error 'SomeCustomError', but it reverted with reason 'some reason'", + ); + expect(errorString).to.include( + path.join( + "test", + "matchers", + "reverted", + "revertedWithCustomError.ts", + ), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithPanic.ts b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithPanic.ts new file mode 100644 index 0000000000..4745bfb7f5 --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithPanic.ts @@ -0,0 +1,340 @@ +import type { MatchersContract } from "../../helpers/contracts.js"; +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +import path from "node:path"; +import { before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { + assertThrowsHardhatError, + useFixtureProject, +} from "@nomicfoundation/hardhat-test-utils"; +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../../src/internal/add-chai-matchers.js"; +import { PANIC_CODES } from "../../../src/panic.js"; +import { + runSuccessfulAsserts, + runFailedAsserts, + mineSuccessfulTransaction, + initEnvironment, +} from "../../helpers/helpers.js"; + +addChaiMatchers(); + +describe("INTEGRATION: Reverted with panic", { timeout: 60000 }, () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + // deploy Matchers contract before each test + let matchers: MatchersContract; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + before(async () => { + ({ ethers, provider } = await initEnvironment("reverted-with-panic")); + }); + + beforeEach(async () => { + const Matchers = await ethers.getContractFactory<[], MatchersContract>( + "Matchers", + ); + matchers = await Matchers.deploy(); + }); + + describe("calling a method that succeeds", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => expect(x).not.to.be.revertedWithPanic(), + }); + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => + expect(x).not.to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "succeeds", + failedAssert: (x) => expect(x).to.be.revertedWithPanic(), + failedAssertReason: + "Expected transaction to be reverted with some panic code, but it didn't revert", + }); + await runFailedAsserts({ + matchers, + method: "succeeds", + failedAssert: (x) => + expect(x).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + failedAssertReason: + "Expected transaction to be reverted with panic code 0x1 (Assertion error), but it didn't revert", + }); + }); + }); + + describe("calling a method that reverts without a reason", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWithoutReason", + successfulAssert: (x) => expect(x).to.not.be.revertedWithPanic(), + }); + + await runSuccessfulAsserts({ + matchers, + method: "revertsWithoutReason", + successfulAssert: (x) => + expect(x).to.not.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWithoutReason", + failedAssert: (x) => expect(x).to.be.revertedWithPanic(), + failedAssertReason: + "Expected transaction to be reverted with some panic code, but it reverted without a reason", + }); + + await runFailedAsserts({ + matchers, + method: "revertsWithoutReason", + failedAssert: (x) => + expect(x).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + failedAssertReason: + "Expected transaction to be reverted with panic code 0x1 (Assertion error), but it reverted without a reason", + }); + }); + }); + + describe("calling a method that reverts with a reason string", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + successfulAssert: (x) => expect(x).to.not.be.revertedWithPanic(), + }); + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + successfulAssert: (x) => + expect(x).to.not.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + failedAssert: (x) => expect(x).to.be.revertedWithPanic(), + failedAssertReason: + "Expected transaction to be reverted with some panic code, but it reverted with reason 'some reason'", + }); + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + failedAssert: (x) => + expect(x).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + failedAssertReason: + "Expected transaction to be reverted with panic code 0x1 (Assertion error), but it reverted with reason 'some reason'", + }); + }); + }); + + describe("calling a method that reverts with a panic code", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "panicAssert", + successfulAssert: (x) => expect(x).to.be.revertedWithPanic(), + }); + await runSuccessfulAsserts({ + matchers, + method: "panicAssert", + successfulAssert: (x) => + expect(x).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "panicAssert", + failedAssert: (x) => expect(x).to.not.be.revertedWithPanic(), + failedAssertReason: + "Expected transaction NOT to be reverted with some panic code, but it reverted with panic code 0x1 (Assertion error)", + }); + + await runFailedAsserts({ + matchers, + method: "panicAssert", + failedAssert: (x) => + expect(x).to.not.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + failedAssertReason: + "Expected transaction NOT to be reverted with panic code 0x1 (Assertion error), but it was", + }); + + await runFailedAsserts({ + matchers, + method: "panicAssert", + failedAssert: (x) => + expect(x).to.be.revertedWithPanic(PANIC_CODES.ARITHMETIC_OVERFLOW), + failedAssertReason: + "Expected transaction to be reverted with panic code 0x11 (Arithmetic operation overflowed outside of an unchecked block), but it reverted with panic code 0x1 (Assertion error)", + }); + }); + }); + + describe("calling a method that reverts with a custom error", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertWithSomeCustomError", + successfulAssert: (x) => expect(x).to.not.be.revertedWithPanic(), + }); + + await runSuccessfulAsserts({ + matchers, + method: "revertWithSomeCustomError", + successfulAssert: (x) => + expect(x).to.not.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertWithSomeCustomError", + failedAssert: (x) => expect(x).to.be.revertedWithPanic(), + failedAssertReason: + "Expected transaction to be reverted with some panic code, but it reverted with a custom error", + }); + + await runFailedAsserts({ + matchers, + method: "revertWithSomeCustomError", + failedAssert: (x) => + expect(x).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR), + failedAssertReason: + "Expected transaction to be reverted with panic code 0x1 (Assertion error), but it reverted with a custom error", + }); + }); + }); + + describe("accepted panic code values", () => { + it("number", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => expect(x).not.to.be.revertedWithPanic(1), + }); + }); + + it("bigint", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => expect(x).not.to.be.revertedWithPanic(1n), + }); + }); + + it("string", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => expect(x).not.to.be.revertedWithPanic("1"), + }); + }); + }); + + describe("invalid values", () => { + it("non-errors as subject", async () => { + await expect( + expect(Promise.reject({})).to.be.revertedWithPanic(1), + ).to.be.rejectedWith(AssertionError, "Expected an Error object"); + }); + + it("non-number as expectation", async () => { + const { hash } = await mineSuccessfulTransaction(provider, ethers); + + assertThrowsHardhatError( + () => expect(hash).to.be.revertedWithPanic("invalid"), + HardhatError.ERRORS.CHAI_MATCHERS.PANIC_CODE_EXPECTED, + { + panicCode: "invalid", + }, + ); + }); + + it("non-number as expectation, subject is a rejected promise", async () => { + const tx = matchers.revertsWithoutReason(); + + assertThrowsHardhatError( + () => expect(tx).to.be.revertedWithPanic("invalid"), + HardhatError.ERRORS.CHAI_MATCHERS.PANIC_CODE_EXPECTED, + { + panicCode: "invalid", + }, + ); + }); + + it("errors that are not related to a reverted transaction", async () => { + // use an address that almost surely doesn't have balance + const randomPrivateKey = + "0xc5c587cc6e48e9692aee0bf07474118e6d830c11905f7ec7ff32c09c99eba5f9"; + const signer = new ethers.Wallet(randomPrivateKey, ethers.provider); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the contract is of type MatchersContract + const matchersFromSenderWithoutFunds = matchers.connect( + signer, + ) as MatchersContract; + + // this transaction will fail because of lack of funds, not because of a + // revert + await expect( + expect( + matchersFromSenderWithoutFunds.revertsWithoutReason({ + gasLimit: 1_000_000, + }), + ).to.not.be.revertedWithPanic(), + ).to.be.eventually.rejectedWith( + "Sender doesn't have enough funds to send tx", + ); + }); + }); + + describe("stack traces", () => { + // smoke test for stack traces + it("includes test file", async () => { + try { + await expect(matchers.panicAssert()).to.not.be.revertedWithPanic(); + } catch (e) { + const errorString = util.inspect(e); + expect(errorString).to.include( + "Expected transaction NOT to be reverted with some panic code, but it reverted with panic code 0x1 (Assertion error)", + ); + expect(errorString).to.include( + path.join("test", "matchers", "reverted", "revertedWithPanic.ts"), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithoutReason.ts b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithoutReason.ts new file mode 100644 index 0000000000..4168ebf2ea --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/matchers/reverted/revertedWithoutReason.ts @@ -0,0 +1,213 @@ +import type { MatchersContract } from "../../helpers/contracts.js"; +import type { HardhatEthers } from "@ignored/hardhat-vnext-ethers/types"; + +import path from "node:path"; +import { before, beforeEach, describe, it } from "node:test"; +import util from "node:util"; + +import { useFixtureProject } from "@nomicfoundation/hardhat-test-utils"; +import { AssertionError, expect } from "chai"; + +import { addChaiMatchers } from "../../../src/internal/add-chai-matchers.js"; +import { + runSuccessfulAsserts, + runFailedAsserts, + initEnvironment, +} from "../../helpers/helpers.js"; + +addChaiMatchers(); + +describe("INTEGRATION: Reverted without reason", { timeout: 60000 }, () => { + describe("with the in-process hardhat network", () => { + useFixtureProject("hardhat-project"); + runTests(); + }); + + function runTests() { + // deploy Matchers contract before each test + let matchers: MatchersContract; + + let ethers: HardhatEthers; + + before(async () => { + ({ ethers } = await initEnvironment("reverted-without-reason")); + }); + + beforeEach(async () => { + const Matchers = await ethers.getContractFactory<[], MatchersContract>( + "Matchers", + ); + matchers = await Matchers.deploy(); + }); + + // helpers + describe("calling a method that succeeds", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "succeeds", + successfulAssert: (x) => + expect(x).not.to.be.revertedWithoutReason(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "succeeds", + failedAssert: (x) => expect(x).to.be.revertedWithoutReason(ethers), + failedAssertReason: + "Expected transaction to be reverted without a reason, but it didn't revert", + }); + }); + }); + + describe("calling a method that reverts without a reason", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWithoutReason", + args: [], + successfulAssert: (x) => + expect(x).to.be.revertedWithoutReason(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWithoutReason", + args: [], + failedAssert: (x) => + expect(x).to.not.be.revertedWithoutReason(ethers), + failedAssertReason: + "Expected transaction NOT to be reverted without a reason, but it was", + }); + }); + }); + + describe("calling a method that reverts with a reason", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + successfulAssert: (x) => + expect(x).to.not.be.revertedWithoutReason(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertsWith", + args: ["some reason"], + failedAssert: (x) => expect(x).to.be.revertedWithoutReason(ethers), + failedAssertReason: + "Expected transaction to be reverted without a reason, but it reverted with reason 'some reason'", + }); + }); + }); + + describe("calling a method that reverts with a panic code", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "panicAssert", + successfulAssert: (x) => + expect(x).to.not.be.revertedWithoutReason(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "panicAssert", + failedAssert: (x) => expect(x).to.be.revertedWithoutReason(ethers), + failedAssertReason: + "Expected transaction to be reverted without a reason, but it reverted with panic code 0x1 (Assertion error)", + }); + }); + }); + + describe("calling a method that reverts with a custom error", () => { + it("successful asserts", async () => { + await runSuccessfulAsserts({ + matchers, + method: "revertWithSomeCustomError", + successfulAssert: (x) => + expect(x).to.not.be.revertedWithoutReason(ethers), + }); + }); + + it("failed asserts", async () => { + await runFailedAsserts({ + matchers, + method: "revertWithSomeCustomError", + failedAssert: (x) => expect(x).to.be.revertedWithoutReason(ethers), + failedAssertReason: + "Expected transaction to be reverted without a reason, but it reverted with a custom error", + }); + }); + }); + + describe("invalid values", () => { + it("non-errors as subject", async () => { + await expect( + expect(Promise.reject({})).to.be.revertedWithoutReason(ethers), + ).to.be.rejectedWith(AssertionError, "Expected an Error object"); + }); + + it("errors that are not related to a reverted transaction", async () => { + // use an address that almost surely doesn't have balance + const randomPrivateKey = + "0xc5c587cc6e48e9692aee0bf07474118e6d830c11905f7ec7ff32c09c99eba5f9"; + + const signer = new ethers.Wallet(randomPrivateKey, ethers.provider); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the contract is of type MatchersContract + const matchersFromSenderWithoutFunds = matchers.connect( + signer, + ) as MatchersContract; + + // this transaction will fail because of lack of funds, not because of a + // revert + await expect( + expect( + matchersFromSenderWithoutFunds.revertsWithoutReason({ + gasLimit: 1_000_000, + }), + ).to.not.be.revertedWithoutReason(ethers), + ).to.be.eventually.rejectedWith( + "Sender doesn't have enough funds to send tx", + ); + }); + }); + + describe("stack traces", () => { + // smoke test for stack traces + it("includes test file", async () => { + try { + await expect( + matchers.revertsWithoutReason(), + ).to.not.be.revertedWithoutReason(ethers); + } catch (e) { + const errorString = util.inspect(e); + expect(errorString).to.include( + "Expected transaction NOT to be reverted without a reason, but it was", + ); + expect(errorString).to.include( + path.join( + "test", + "matchers", + "reverted", + "revertedWithoutReason.ts", + ), + ); + return; + } + expect.fail("Expected an exception but none was thrown"); + }); + }); + } +}); diff --git a/v-next/hardhat-chai-matchers/test/multi-network-connections.ts b/v-next/hardhat-chai-matchers/test/multi-network-connections.ts new file mode 100644 index 0000000000..c9f4060ccd --- /dev/null +++ b/v-next/hardhat-chai-matchers/test/multi-network-connections.ts @@ -0,0 +1,97 @@ +import type { EthereumProvider } from "@ignored/hardhat-vnext/types/providers"; +import type { + HardhatEthers, + HardhatEthersSigner, +} from "@ignored/hardhat-vnext-ethers/types"; + +import assert from "node:assert/strict"; +import { before, beforeEach, describe, it } from "node:test"; + +import { createHardhatRuntimeEnvironment } from "@ignored/hardhat-vnext/hre"; +import hardhatEthersPlugin from "@ignored/hardhat-vnext-ethers"; +import { expect } from "chai"; + +import hardhatChaiMatchersPlugin from "../src/index.js"; + +describe("handle multiple connections", () => { + let sender: HardhatEthersSigner; + let receiver: HardhatEthersSigner; + + let sender2: HardhatEthersSigner; + let receiver2: HardhatEthersSigner; + + let provider: EthereumProvider; + let ethers: HardhatEthers; + + let provider2: EthereumProvider; + let ethers2: HardhatEthers; + + before(async () => { + const hre = await createHardhatRuntimeEnvironment({ + plugins: [hardhatChaiMatchersPlugin, hardhatEthersPlugin], + networks: { + test1: { + type: "edr", + chainId: 1, + }, + test2: { + type: "edr", + chainId: 2, + }, + }, + }); + + ({ ethers, provider } = await hre.network.connect("test1")); + + ({ ethers: ethers2, provider: provider2 } = + await hre.network.connect("test2")); + }); + + beforeEach(async () => { + const wallets = await ethers.getSigners(); + sender = wallets[0]; + receiver = wallets[1]; + + const wallets2 = await ethers2.getSigners(); + sender2 = wallets2[0]; + receiver2 = wallets2[1]; + }); + + describe("it should handle 2 separate connections", () => { + it("should modify the balance only in the first connection, not te second one", async () => { + // Be sure that the addresses in the 2 networks are the same + assert.equal(sender.address, sender2.address); + assert.equal(receiver.address, receiver2.address); + + // Send a transaction from the first connection + let nonceSender = await sender.getNonce(); + let nonceSender2 = await sender2.getNonce(); + + await expect(() => + sender.sendTransaction({ + to: receiver.address, + value: 200, + }), + ).to.changeEtherBalance(provider, sender, "-200"); + + // Only the sender nonce should be changed + assert.equal(await sender.getNonce(), nonceSender + 1); + assert.equal(await sender2.getNonce(), nonceSender2); + + // Send a transaction from the second connection + nonceSender = await sender.getNonce(); + nonceSender2 = await sender2.getNonce(); + + await expect(() => + sender2.sendTransaction({ + to: receiver2.address, + value: 200, + }), + ).to.changeEtherBalance(provider2, sender2, "-200"); + + // Only the sender2 nonce should be changed + assert.equal(await sender.getNonce(), nonceSender); + assert.equal(await sender2.getNonce(), nonceSender2 + 1); + }); + }); +}); diff --git a/v-next/hardhat-chai-matchers/tsconfig.json b/v-next/hardhat-chai-matchers/tsconfig.json new file mode 100644 index 0000000000..75e99c964a --- /dev/null +++ b/v-next/hardhat-chai-matchers/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../config-v-next/tsconfig.json", + "references": [ + { + "path": "../hardhat" + }, + { + "path": "../hardhat-errors" + }, + { + "path": "../hardhat-ethers" + }, + + { + "path": "../hardhat-mocha-test-runner" + }, + { + "path": "../hardhat-node-test-reporter" + }, + { + "path": "../hardhat-test-utils" + }, + { + "path": "../hardhat-utils" + } + ] +} diff --git a/v-next/hardhat-errors/src/descriptors.ts b/v-next/hardhat-errors/src/descriptors.ts index 9459afe181..14adb2a885 100644 --- a/v-next/hardhat-errors/src/descriptors.ts +++ b/v-next/hardhat-errors/src/descriptors.ts @@ -86,6 +86,11 @@ export const ERROR_CATEGORIES: { SOLIDITY: { min: 1200, max: 1299, websiteTitle: "Solidity errors" }, VIEM: { min: 1300, max: 1399, websiteTitle: "Hardhat-viem errors" }, NODE: { min: 1400, max: 1499, websiteTitle: "Hardhat node errors" }, + CHAI_MATCHERS: { + min: 1500, + max: 1599, + websiteTitle: "Hardhat-chai-matchers errors", + }, }; export const ERRORS = { @@ -1258,4 +1263,176 @@ Please check Hardhat's output for more details.`, websiteDescription: `The node only supports the 'edr' network type.`, }, }, + CHAI_MATCHERS: { + UNKNOWN_COMPARISON_OPERATION: { + number: 1500, + messageTemplate: `Unknown comparison operation "{method}"`, + websiteTitle: "Unknown comparison operation", + websiteDescription: "Unknown comparison operation", + }, + EXPECTED_STRING_OR_ADDRESSABLE: { + number: 1501, + messageTemplate: `Expected string or addressable, but got {account}`, + websiteTitle: "Expected string or addressable", + websiteDescription: "Expected string or addressable", + }, + ASSERTION_WITHOUT_ERROR_MESSAGE: { + number: 1502, + messageTemplate: `Assertion doesn't have an error message. Please open an issue to report this.`, + websiteTitle: "Assertion doesn't have an error message", + websiteDescription: `Assertion doesn't have an error message. Please open an issue to report this.`, + shouldBeReported: true, + }, + MATCHER_CANNOT_BE_CHAINED_AFTER: { + number: 1503, + messageTemplate: `The matcher "{matcher}" cannot be chained after "{previousMatcher}". For more information, please refer to the documentation at: (https://hardhat.org/chaining-async-matchers).`, + websiteTitle: "Matcher cannot be chained after", + websiteDescription: `The matcher cannot be chained after another matcher. Please open an issue to report this.`, + }, + DECODING_ERROR: { + number: 1504, + messageTemplate: `There was an error decoding "{encodedData}" as a "{type}. Reason: {reason}"`, + websiteTitle: "Error while decoding data", + websiteDescription: `There was an error decoding data`, + }, + EXPECTED_VALID_TRANSACTION_HASH: { + number: 1505, + messageTemplate: `Expected a valid transaction hash, but got "{hash}"`, + websiteTitle: "Expected a valid transaction hash", + websiteDescription: `Expected a valid transaction hash`, + }, + EXPECT_STRING_OR_REGEX_AS_REVERT_REASON: { + number: 1506, + messageTemplate: + "Expected the revert reason to be a string or a regular expression", + websiteTitle: + "Expected the revert reason to be a string or a regular expression", + websiteDescription: + "Expected the revert reason to be a string or a regular expression", + }, + FIRST_ARGUMENT_MUST_BE_A_CONTRACT: { + number: 1507, + messageTemplate: + "The first argument of .revertedWithCustomError must be the contract that defines the custom error", + websiteTitle: "First argument must be a contract", + websiteDescription: "First argument must be a contract", + }, + STRING_EXPECTED_AS_CUSTOM_ERROR_NAME: { + number: 1508, + messageTemplate: "Expected the custom error name to be a string", + websiteTitle: "Expected the custom error name to be a string", + websiteDescription: "Expected the custom error name to be a string", + }, + CONTRACT_DOES_NOT_HAVE_CUSTOM_ERROR: { + number: 1509, + messageTemplate: `The given contract doesn't have a custom error named "{customErrorName}"`, + websiteTitle: + "Contract doesn't have a custom error with the specified name", + websiteDescription: + "Contract doesn't have a custom error with the specified name", + }, + REVERT_INVALID_ARGUMENTS_LENGTH: { + number: 1510, + messageTemplate: + "The .revertedWithCustomError matcher expects two arguments: the contract and the custom error name. Arguments should be asserted with the .withArgs helper.", + websiteTitle: + "Invalid arguments length for the .revertedWithCustomError matcher", + websiteDescription: + "Invalid arguments length for the .revertedWithCustomError matcher", + }, + WITH_ARGS_FORBIDDEN: { + number: 1511, + messageTemplate: + "[.withArgs] should never happen, please submit an issue to the Hardhat repository", + websiteTitle: + "[.withArgs] should never happen, please submit an issue to the Hardhat repository", + websiteDescription: + "[.withArgs] should never happen, please submit an issue to the Hardhat repository", + }, + INDEXED_EVENT_FORBIDDEN: { + number: 1512, + messageTemplate: + "Should not get an indexed event when the assertion type is not event. Please open an issue about this.", + websiteTitle: + "Should not get an indexed event when the assertion type is not event", + websiteDescription: + "Should not get an indexed event when the assertion type is not event", + }, + PANIC_CODE_EXPECTED: { + number: 1513, + messageTemplate: `Expected the given panic code to be a number-like value, but got "{panicCode}"`, + websiteTitle: "Expected the given panic code to be a number-like value", + websiteDescription: + "Expected the given panic code to be a number-like value", + }, + ACCOUNTS_NUMBER_DIFFERENT_FROM_BALANCE_CHANGES: { + number: 1514, + messageTemplate: `The number of accounts ({accounts}) is different than the number of expected balance changes ({balanceChanges})`, + websiteTitle: + "The number of accounts is different than the number of expected balance changes", + websiteDescription: + "The number of accounts is different than the number of expected balance changes", + }, + FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE: { + number: 1515, + messageTemplate: `The first argument of "{method}" must be the contract instance of the token`, + websiteTitle: "First argument must be a contract instance", + websiteDescription: "First argument must be a contract instance", + }, + CONTRACT_IS_NOT_AN_ERC20_TOKEN: { + number: 1516, + messageTemplate: `The given contract instance is not an ERC20 token`, + websiteTitle: "Given contract instance is not an ERC20 token", + websiteDescription: "Given contract instance is not an ERC20 token", + }, + INVALID_TRANSACTION: { + number: 1517, + messageTemplate: "{transaction} is not a valid transaction", + websiteTitle: "Invalid transaction", + websiteDescription: "Invalid transaction", + }, + CONTRACT_TARGET_MUST_BE_A_STRING: { + number: 1518, + messageTemplate: "The contract target should be a string", + websiteTitle: "Contract target must be a string", + websiteDescription: "Contract target must be a string", + }, + EMIT_EXPECTS_TWO_ARGUMENTS: { + number: 1519, + messageTemplate: + "The .emit matcher expects two arguments: the contract and the event name. Arguments should be asserted with the .withArgs helper.", + websiteTitle: "Invalid arguments length for the .emit matcher", + websiteDescription: "Invalid arguments length for the .emit matcher", + }, + CONTRACT_RUNNER_PROVIDER_NOT_NULL: { + number: 1520, + messageTemplate: "contract.runner.provider shouldn't be null", + websiteTitle: "Contract runner's provider shouldn't be null", + websiteDescription: "Contract runner's provider shouldn't be null", + }, + WITH_ARGS_CANNOT_BE_COMBINED_WITH_NOT: { + number: 1521, + messageTemplate: "Do not combine .not. with .withArgs()", + websiteTitle: "Do not combine .not. with .withArgs()", + websiteDescription: "Do not combine .not. with .withArgs()", + }, + WITH_ARGS_WRONG_COMBINATION: { + number: 1522, + messageTemplate: + "withArgs can only be used in combination with a previous .emit or .revertedWithCustomError assertion", + websiteTitle: + "withArgs can only be used in combination with a previous .emit or .revertedWithCustomError assertion", + websiteDescription: + "withArgs can only be used in combination with a previous .emit or .revertedWithCustomError assertion", + }, + WITH_ARGS_COMBINED_WITH_INCOMPATIBLE_ASSERTIONS: { + number: 1523, + messageTemplate: + "withArgs called with both .emit and .revertedWithCustomError, but these assertions cannot be combined", + websiteTitle: + "withArgs called with both .emit and .revertedWithCustomError, but these assertions cannot be combined", + websiteDescription: + "withArgs called with both .emit and .revertedWithCustomError, but these assertions cannot be combined", + }, + }, } as const;