From ef961128c639d434837ead6b643a9714c1000202 Mon Sep 17 00:00:00 2001 From: John Kane Date: Thu, 27 Nov 2025 09:55:29 +0000 Subject: [PATCH 1/5] chore: add reproducible example Adds another example project under v-next to allow replicating the issue. --- pnpm-lock.yaml | 252 +++++++- v-next/example-project-assertion/.gitignore | 23 + v-next/example-project-assertion/LICENSE | 9 + v-next/example-project-assertion/README.md | 30 + .../contracts/ECDSA.sol | 414 +++++++++++++ .../contracts/MockToken.sol | 15 + .../contracts/SafeERC20.sol | 550 ++++++++++++++++++ .../contracts/Signature.sol | 230 ++++++++ .../contracts/interfaces/IDaiLikePermit.sol | 29 + .../contracts/interfaces/IERC7597Permit.sol | 26 + .../contracts/interfaces/IPermit2.sol | 102 ++++ .../contracts/interfaces/IWETH.sol | 32 + .../libraries/RevertReasonForwarder.sol | 39 ++ .../hardhat.config.ts | 38 ++ v-next/example-project-assertion/package.json | 46 ++ .../scripts/test-closed-connection.ts | 21 + .../test/Assertion.ts | 133 +++++ .../example-project-assertion/tsconfig.json | 50 ++ 18 files changed, 2035 insertions(+), 4 deletions(-) create mode 100644 v-next/example-project-assertion/.gitignore create mode 100644 v-next/example-project-assertion/LICENSE create mode 100644 v-next/example-project-assertion/README.md create mode 100644 v-next/example-project-assertion/contracts/ECDSA.sol create mode 100644 v-next/example-project-assertion/contracts/MockToken.sol create mode 100644 v-next/example-project-assertion/contracts/SafeERC20.sol create mode 100644 v-next/example-project-assertion/contracts/Signature.sol create mode 100644 v-next/example-project-assertion/contracts/interfaces/IDaiLikePermit.sol create mode 100644 v-next/example-project-assertion/contracts/interfaces/IERC7597Permit.sol create mode 100644 v-next/example-project-assertion/contracts/interfaces/IPermit2.sol create mode 100644 v-next/example-project-assertion/contracts/interfaces/IWETH.sol create mode 100644 v-next/example-project-assertion/contracts/libraries/RevertReasonForwarder.sol create mode 100644 v-next/example-project-assertion/hardhat.config.ts create mode 100644 v-next/example-project-assertion/package.json create mode 100644 v-next/example-project-assertion/scripts/test-closed-connection.ts create mode 100644 v-next/example-project-assertion/test/Assertion.ts create mode 100644 v-next/example-project-assertion/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12c5d447389..2b62ae8c3d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,66 @@ importers: specifier: ^2.43.0 version: 2.43.5(typescript@5.8.3)(zod@3.25.76) + v-next/example-project-assertion: + devDependencies: + '@metamask/eth-sig-util': + specifier: ^8.2.0 + version: 8.2.0 + '@nomicfoundation/hardhat-errors': + specifier: workspace:^3.0.5 + version: link:../hardhat-errors + '@nomicfoundation/hardhat-toolbox-mocha-ethers': + specifier: workspace:^3.0.1 + version: link:../hardhat-toolbox-mocha-ethers + '@openzeppelin/contracts': + specifier: 5.1.0 + version: 5.1.0 + '@types/chai': + specifier: ^4.2.0 + version: 4.3.20 + '@types/mocha': + specifier: '>=10.0.10' + version: 10.0.10 + '@types/node': + specifier: ^20.14.9 + version: 20.19.33 + '@uniswap/v4-core': + specifier: 1.0.2 + version: 1.0.2 + chai: + specifier: ^5.1.2 + version: 5.3.3 + ethers: + specifier: ^6.14.0 + version: 6.15.0 + forge-std: + specifier: foundry-rs/forge-std#v1.9.4 + version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/1eea5bae12ae557d589f9f0f0edae2faa47cb262 + hardhat: + specifier: workspace:^3.0.15 + version: link:../hardhat + keccak256: + specifier: ^1.0.6 + version: 1.0.6 + merkletreejs: + specifier: ^0.6.0 + version: 0.6.0 + mocha: + specifier: ^11.0.0 + version: 11.7.3 + permit2: + specifier: uniswap/permit2#cc56ad0f3439c502c246fc5cfcc3db92bb8b7219 + version: '@uniswap/permit2@https://codeload.github.com/uniswap/permit2/tar.gz/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219' + prettier: + specifier: 3.2.5 + version: 3.2.5 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + typescript: + specifier: ~5.8.0 + version: 5.8.3 + v-next/hardhat: dependencies: '@nomicfoundation/edr': @@ -2420,6 +2480,22 @@ packages: resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ethereumjs/common@3.2.0': + resolution: {integrity: sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA==} + + '@ethereumjs/rlp@4.0.1': + resolution: {integrity: sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==} + engines: {node: '>=14'} + hasBin: true + + '@ethereumjs/tx@4.2.0': + resolution: {integrity: sha512-1nc6VO4jtFd172BbSnTnDQVr9IYBFl1y4xPzZdtkrkKIncBCkdbgfdRV+MiTkJYAtTxvV12GRZLqBFT1PNK6Yw==} + engines: {node: '>=14'} + + '@ethereumjs/util@8.1.0': + resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} + engines: {node: '>=14'} + '@ethersproject/abi@5.8.0': resolution: {integrity: sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==} @@ -2616,6 +2692,22 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@metamask/abi-utils@3.0.0': + resolution: {integrity: sha512-a/l0DiSIr7+CBYVpHygUa3ztSlYLFCQMsklLna+t6qmNY9+eIO5TedNxhyIyvaJ+4cN7TLy0NQFbp9FV3X2ktg==} + engines: {node: ^18.18 || ^20.14 || >=22} + + '@metamask/eth-sig-util@8.2.0': + resolution: {integrity: sha512-LZDglIh4gYGw9Myp+2aIwKrj6lIJpMC4e0m7wKJU+BxLLBFcrTgKrjdjstXGVWvuYG3kutlh9J+uNBRPJqffWQ==} + engines: {node: ^18.18 || ^20.14 || >=22} + + '@metamask/superstruct@3.2.1': + resolution: {integrity: sha512-fLgJnDOXFmuVlB38rUN5SmU7hAFQcCjrg3Vrxz67KTY7YHFnSNEKvX4avmEBdOI0yTCxZjwMCFEqsC8k2+Wd3g==} + engines: {node: '>=16.0.0'} + + '@metamask/utils@11.8.1': + resolution: {integrity: sha512-DIbsNUyqWLFgqJlZxi1OOCMYvI23GqFCvNJAtzv8/WXWzJfnJnvp1M24j7VvUe3URBi3S86UgQ7+7aWU9p/cnQ==} + engines: {node: ^18.18 || ^20.14 || >=22} + '@microsoft/api-extractor-model@7.30.2': resolution: {integrity: sha512-3/t2F+WhkJgBzSNwlkTIL0tBgUoBqDqL66pT+nh2mPbM0NIDGVGtpqbGWPgHIzn/mn7kGS/Ep8D8po58e8UUIw==} @@ -3019,6 +3111,9 @@ packages: '@types/chai-as-promised@8.0.2': resolution: {integrity: sha512-meQ1wDr1K5KRCSvG2lX7n7/5wf70BeptTKst0axGvnN6zqaVpRqegoIbugiAPSqOW9K9aL8gDVrm7a2LXOtn2Q==} + '@types/chai@4.3.20': + resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3078,6 +3173,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@20.19.33': + resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@22.18.7': resolution: {integrity: sha512-3E97nlWEVp2V6J7aMkR8eOnw/w0pArPwf/5/W0865f+xzBoGL/ZuHkTAKAGN7cOWNwd+sG+hZOqj+fjzeHS75g==} @@ -3522,9 +3620,15 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-reverse@1.0.1: + resolution: {integrity: sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + c8@9.1.0: resolution: {integrity: sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==} engines: {node: '>=14.14.0'} @@ -3577,6 +3681,10 @@ packages: peerDependencies: chai: '>= 2.1.2 < 7' + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3680,6 +3788,11 @@ packages: cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -4786,6 +4899,9 @@ packages: resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==} hasBin: true + keccak256@1.0.6: + resolution: {integrity: sha512-8GLiM01PkdJVGUhR1e6M/AvWnSqYS0HaERI+K/QtStGDGlSTx2B1zTqZk4Zlqu5TxHJNTxWAdP9Y+WI50OApUw==} + keccak@3.0.4: resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==} engines: {node: '>=10.0.0'} @@ -4849,6 +4965,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4892,12 +5011,19 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + merkletreejs@0.6.0: + resolution: {integrity: sha512-cyiratjG7fyHsa4DVfYVPxcoAh3zmUuOPItIfZex8f0pUVptNEmiiTOoeS0JnDDTWy+n3FKnI0K1gCzti7rGMg==} + engines: {node: '>= 7.6.0'} + mermaid@10.9.3: resolution: {integrity: sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==} micro-eth-signer@0.14.0: resolution: {integrity: sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw==} + micro-ftch@0.3.1: + resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==} + micro-packed@0.7.3: resolution: {integrity: sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==} @@ -5256,6 +5382,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5275,6 +5405,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pony-cause@2.1.11: + resolution: {integrity: sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==} + engines: {node: '>=12.0.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5753,6 +5887,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + treeify@1.1.0: + resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} + engines: {node: '>=0.6'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -5807,6 +5945,9 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6667,6 +6808,26 @@ snapshots: '@eslint/core': 0.13.0 levn: 0.4.1 + '@ethereumjs/common@3.2.0': + dependencies: + '@ethereumjs/util': 8.1.0 + crc-32: 1.2.2 + + '@ethereumjs/rlp@4.0.1': {} + + '@ethereumjs/tx@4.2.0': + dependencies: + '@ethereumjs/common': 3.2.0 + '@ethereumjs/rlp': 4.0.1 + '@ethereumjs/util': 8.1.0 + ethereum-cryptography: 2.2.1 + + '@ethereumjs/util@8.1.0': + dependencies: + '@ethereumjs/rlp': 4.0.1 + ethereum-cryptography: 2.2.1 + micro-ftch: 0.3.1 + '@ethersproject/abi@5.8.0': dependencies: '@ethersproject/address': 5.8.0 @@ -7011,6 +7172,43 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@metamask/abi-utils@3.0.0': + dependencies: + '@metamask/superstruct': 3.2.1 + '@metamask/utils': 11.8.1 + transitivePeerDependencies: + - supports-color + + '@metamask/eth-sig-util@8.2.0': + dependencies: + '@ethereumjs/rlp': 4.0.1 + '@ethereumjs/util': 8.1.0 + '@metamask/abi-utils': 3.0.0 + '@metamask/utils': 11.8.1 + '@scure/base': 1.1.9 + ethereum-cryptography: 2.2.1 + tweetnacl: 1.0.3 + transitivePeerDependencies: + - supports-color + + '@metamask/superstruct@3.2.1': {} + + '@metamask/utils@11.8.1': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@metamask/superstruct': 3.2.1 + '@noble/hashes': 1.7.1 + '@scure/base': 1.2.6 + '@types/debug': 4.1.12 + '@types/lodash': 4.17.20 + debug: 4.4.3 + lodash: 4.17.21 + pony-cause: 2.1.11 + semver: 7.7.2 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + '@microsoft/api-extractor-model@7.30.2(@types/node@22.18.7)': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -7397,6 +7595,8 @@ snapshots: dependencies: '@types/chai': 5.2.3 + '@types/chai@4.3.20': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -7455,6 +7655,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@20.19.33': + dependencies: + undici-types: 6.21.0 + '@types/node@22.18.7': dependencies: undici-types: 6.21.0 @@ -7901,11 +8105,18 @@ snapshots: node-releases: 2.0.21 update-browserslist-db: 1.1.3(browserslist@4.26.2) + buffer-reverse@1.0.1: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + c8@9.1.0: dependencies: '@bcoe/v8-coverage': 0.2.3 @@ -7975,6 +8186,14 @@ snapshots: chai: 6.2.2 check-error: 2.1.1 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@2.4.2: @@ -8068,6 +8287,8 @@ snapshots: dependencies: layout-base: 1.0.2 + crc-32@1.2.2: {} + create-require@1.1.1: {} cross-env@7.0.3: @@ -9322,6 +9543,12 @@ snapshots: dependencies: commander: 8.3.0 + keccak256@1.0.6: + dependencies: + bn.js: 5.2.2 + buffer: 6.0.3 + keccak: 3.0.4 + keccak@3.0.4: dependencies: node-addon-api: 2.0.2 @@ -9376,6 +9603,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.2.4: {} @@ -9439,6 +9668,12 @@ snapshots: merge2@1.4.1: {} + merkletreejs@0.6.0: + dependencies: + buffer-reverse: 1.0.1 + crypto-js: 4.2.0 + treeify: 1.1.0 + mermaid@10.9.3: dependencies: '@braintree/sanitize-url': 6.0.4 @@ -9467,9 +9702,11 @@ snapshots: micro-eth-signer@0.14.0: dependencies: '@noble/curves': 1.8.2 - '@noble/hashes': 1.7.1 + '@noble/hashes': 1.7.2 micro-packed: 0.7.3 + micro-ftch@0.3.1: {} + micro-packed@0.7.3: dependencies: '@scure/base': 1.2.6 @@ -9948,6 +10185,8 @@ snapshots: path-type@4.0.0: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -9960,6 +10199,8 @@ snapshots: dependencies: find-up: 4.1.0 + pony-cause@2.1.11: {} + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} @@ -10185,7 +10426,7 @@ snapshots: rxjs@7.8.2: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 sade@1.8.1: dependencies: @@ -10511,6 +10752,8 @@ snapshots: dependencies: is-number: 7.0.0 + treeify@1.1.0: {} + ts-api-utils@2.1.0(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -10555,8 +10798,7 @@ snapshots: tslib@2.7.0: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.20.6: dependencies: @@ -10571,6 +10813,8 @@ snapshots: tunnel@0.0.6: {} + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/v-next/example-project-assertion/.gitignore b/v-next/example-project-assertion/.gitignore new file mode 100644 index 00000000000..58232657237 --- /dev/null +++ b/v-next/example-project-assertion/.gitignore @@ -0,0 +1,23 @@ +# Node modules +/node_modules + +# Compilation output +/dist + +# pnpm deploy output +/bundle + +# Hardhat Build Artifacts +/artifacts + +# Hardhat compilation (v2) support directory +/cache + +# Types generated by typechain +/types + +# Ignition deployment output +/ignition/deployments + +# Hardhat coverage reports +/coverage diff --git a/v-next/example-project-assertion/LICENSE b/v-next/example-project-assertion/LICENSE new file mode 100644 index 00000000000..0781b4a8191 --- /dev/null +++ b/v-next/example-project-assertion/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/example-project-assertion/README.md b/v-next/example-project-assertion/README.md new file mode 100644 index 00000000000..4ba40d10d00 --- /dev/null +++ b/v-next/example-project-assertion/README.md @@ -0,0 +1,30 @@ +# Reproduction of mocha assertion error + +This is a deletable example project to reproduce an assertion error that +can happen with a missing await. The unpredictability of cleanup in a larger +suite means this may not work for you. + +To run the example: + +```shell +cd v-next/example-project-assertion +pnpm hardhat test mocha + +# No contracts to compile + +# Running Mocha tests + + +# Hardhat3 test +# ✔ Should transfer money to another wallet with extra value (331ms) + +# Unhandled promise rejection: + +# HardhatError: HHE100: An internal invariant was violated: The block doesn't exist +# at assertHardhatInvariant (/workspaces/hardhat/v-next/hardhat-errors/src/errors.ts:237:11) +# at getBalanceChange (/workspaces/hardhat/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalance.ts:100:3) +# at async Promise.all (index 0) +``` + +There is also a test script under `./scripts/run-test-with-cleanup.js` that tries +to recreate the issue by closing the connection - it is unsuccessful. diff --git a/v-next/example-project-assertion/contracts/ECDSA.sol b/v-next/example-project-assertion/contracts/ECDSA.sol new file mode 100644 index 00000000000..603fd37971f --- /dev/null +++ b/v-next/example-project-assertion/contracts/ECDSA.sol @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/interfaces/IERC1271.sol"; + +/** + * @title ECDSA signature operations + * @notice Provides functions for recovering addresses from signatures and verifying signatures, including support for EIP-2098 compact signatures. + */ +library ECDSA { + // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature + // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines + // the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most + // signatures from current libraries generate a unique signature with an s-value in the lower half order. + // + // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value + // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or + // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept + // these malleable signatures as well. + uint256 private constant _S_BOUNDARY = + 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0 + 1; + uint256 private constant _COMPACT_S_MASK = + 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; + uint256 private constant _COMPACT_V_SHIFT = 255; + + /** + * @notice Recovers the signer's address from the signature. + * @dev Recovers the address that has signed a hash with `(v, r, s)` signature. + * @param hash The keccak256 hash of the data signed. + * @param v The recovery byte of the signature. + * @param r The first 32 bytes of the signature. + * @param s The second 32 bytes of the signature. + * @return signer The address of the signer. + */ + function recover( + bytes32 hash, + uint8 v, + bytes32 r, + bytes32 s + ) internal view returns (address signer) { + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + if lt(s, _S_BOUNDARY) { + let ptr := mload(0x40) + + mstore(ptr, hash) + mstore(add(ptr, 0x20), v) + mstore(add(ptr, 0x40), r) + mstore(add(ptr, 0x60), s) + mstore(0, 0) + pop(staticcall(gas(), 0x1, ptr, 0x80, 0, 0x20)) + signer := mload(0) + } + } + } + + /** + * @notice Recovers the signer's address from the signature using `r` and `vs` components. + * @dev Recovers the address that has signed a hash with `r` and `vs`, where `vs` combines `v` and `s`. + * @param hash The keccak256 hash of the data signed. + * @param r The first 32 bytes of the signature. + * @param vs The combined `v` and `s` values of the signature. + * @return signer The address of the signer. + */ + function recover( + bytes32 hash, + bytes32 r, + bytes32 vs + ) internal view returns (address signer) { + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let s := and(vs, _COMPACT_S_MASK) + if lt(s, _S_BOUNDARY) { + let ptr := mload(0x40) + + mstore(ptr, hash) + mstore(add(ptr, 0x20), add(27, shr(_COMPACT_V_SHIFT, vs))) + mstore(add(ptr, 0x40), r) + mstore(add(ptr, 0x60), s) + mstore(0, 0) + pop(staticcall(gas(), 0x1, ptr, 0x80, 0, 0x20)) + signer := mload(0) + } + } + } + + /** + * @notice Recovers the signer's address from a hash and a signature. + * @param hash The keccak256 hash of the signed data. + * @param signature The full signature from which the signer will be recovered. + * @return signer The address of the signer. + */ + /// @dev WARNING!!! + /// There is a known signature malleability issue with two representations of signatures! + /// Even though this function is able to verify both standard 65-byte and compact 64-byte EIP-2098 signatures + /// one should never use raw signatures for any kind of invalidation logic in their code. + /// As the standard and compact representations are interchangeable any invalidation logic that relies on + /// signature uniqueness will get rekt. + /// More info: https://github.com/OpenZeppelin/openzeppelin-contracts/security/advisories/GHSA-4h98-2769-gh6h + function recover( + bytes32 hash, + bytes calldata signature + ) internal view returns (address signer) { + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + + // memory[ptr:ptr+0x80] = (hash, v, r, s) + switch signature.length + case 65 { + // memory[ptr+0x20:ptr+0x80] = (v, r, s) + mstore( + add(ptr, 0x20), + byte(0, calldataload(add(signature.offset, 0x40))) + ) + calldatacopy(add(ptr, 0x40), signature.offset, 0x40) + } + case 64 { + // memory[ptr+0x20:ptr+0x80] = (v, r, s) + let vs := calldataload(add(signature.offset, 0x20)) + mstore(add(ptr, 0x20), add(27, shr(_COMPACT_V_SHIFT, vs))) + calldatacopy(add(ptr, 0x40), signature.offset, 0x20) + mstore(add(ptr, 0x60), and(vs, _COMPACT_S_MASK)) + } + default { + ptr := 0 + } + + if ptr { + if lt(mload(add(ptr, 0x60)), _S_BOUNDARY) { + // memory[ptr:ptr+0x20] = (hash) + mstore(ptr, hash) + + mstore(0, 0) + pop(staticcall(gas(), 0x1, ptr, 0x80, 0, 0x20)) + signer := mload(0) + } + } + } + } + + /** + * @notice Verifies the signature for a hash, either by recovering the signer or using EIP-1271's `isValidSignature` function. + * @dev Attempts to recover the signer's address from the signature; if the address is non-zero, checks if it's valid according to EIP-1271. + * @param signer The address to validate the signature against. + * @param hash The hash of the signed data. + * @param signature The signature to verify. + * @return success True if the signature is verified, false otherwise. + */ + function recoverOrIsValidSignature( + address signer, + bytes32 hash, + bytes calldata signature + ) internal view returns (bool success) { + if (signer == address(0)) return false; + if ( + (signature.length == 64 || signature.length == 65) && + recover(hash, signature) == signer + ) { + return true; + } + return isValidSignature(signer, hash, signature); + } + + /** + * @notice Verifies the signature for a hash, either by recovering the signer or using EIP-1271's `isValidSignature` function. + * @dev Attempts to recover the signer's address from the signature; if the address is non-zero, checks if it's valid according to EIP-1271. + * @param signer The address to validate the signature against. + * @param hash The hash of the signed data. + * @param v The recovery byte of the signature. + * @param r The first 32 bytes of the signature. + * @param s The second 32 bytes of the signature. + * @return success True if the signature is verified, false otherwise. + */ + function recoverOrIsValidSignature( + address signer, + bytes32 hash, + uint8 v, + bytes32 r, + bytes32 s + ) internal view returns (bool success) { + if (signer == address(0)) return false; + if (recover(hash, v, r, s) == signer) { + return true; + } + return isValidSignature(signer, hash, v, r, s); + } + + /** + * @notice Verifies the signature for a hash, either by recovering the signer or using EIP-1271's `isValidSignature` function. + * @dev Attempts to recover the signer's address from the signature; if the address is non-zero, checks if it's valid according to EIP-1271. + * @param signer The address to validate the signature against. + * @param hash The hash of the signed data. + * @param r The first 32 bytes of the signature. + * @param vs The combined `v` and `s` values of the signature. + * @return success True if the signature is verified, false otherwise. + */ + function recoverOrIsValidSignature( + address signer, + bytes32 hash, + bytes32 r, + bytes32 vs + ) internal view returns (bool success) { + if (signer == address(0)) return false; + if (recover(hash, r, vs) == signer) { + return true; + } + return isValidSignature(signer, hash, r, vs); + } + + /** + * @notice Verifies the signature for a given hash, attempting to recover the signer's address or validates it using EIP-1271 for 65-byte signatures. + * @dev Attempts to recover the signer's address from the signature. If the address is a contract, checks if the signature is valid according to EIP-1271. + * @param signer The expected signer's address. + * @param hash The keccak256 hash of the signed data. + * @param r The first 32 bytes of the signature. + * @param vs The last 32 bytes of the signature, with the last byte being the recovery id. + * @return success True if the signature is valid, false otherwise. + */ + function recoverOrIsValidSignature65( + address signer, + bytes32 hash, + bytes32 r, + bytes32 vs + ) internal view returns (bool success) { + if (signer == address(0)) return false; + if (recover(hash, r, vs) == signer) { + return true; + } + return isValidSignature65(signer, hash, r, vs); + } + + /** + * @notice Validates a signature for a hash using EIP-1271, if `signer` is a contract. + * @dev Makes a static call to `signer` with `isValidSignature` function selector from EIP-1271. + * @param signer The address of the signer to validate against, which could be an EOA or a contract. + * @param hash The hash of the signed data. + * @param signature The signature to validate. + * @return success True if the signature is valid according to EIP-1271, false otherwise. + */ + function isValidSignature( + address signer, + bytes32 hash, + bytes calldata signature + ) internal view returns (bool success) { + // (bool success, bytes memory data) = signer.staticcall(abi.encodeWithSelector(IERC1271.isValidSignature.selector, hash, signature)); + // return success && data.length == 32 && abi.decode(data, (bytes4)) == IERC1271.isValidSignature.selector; + bytes4 selector = IERC1271.isValidSignature.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + + mstore(ptr, selector) + mstore(add(ptr, 0x04), hash) + mstore(add(ptr, 0x24), 0x40) + mstore(add(ptr, 0x44), signature.length) + calldatacopy(add(ptr, 0x64), signature.offset, signature.length) + if staticcall(gas(), signer, ptr, add(0x64, signature.length), 0, 0x20) { + success := and(eq(selector, mload(0)), eq(returndatasize(), 0x20)) + } + } + } + + /** + * @notice Validates a signature for a hash using EIP-1271, if `signer` is a contract. + * @dev Makes a static call to `signer` with `isValidSignature` function selector from EIP-1271. + * @param signer The address of the signer to validate against, which could be an EOA or a contract. + * @param hash The hash of the signed data. + * @param v The recovery byte of the signature. + * @param r The first 32 bytes of the signature. + * @param s The second 32 bytes of the signature. + * @return success True if the signature is valid according to EIP-1271, false otherwise. + */ + function isValidSignature( + address signer, + bytes32 hash, + uint8 v, + bytes32 r, + bytes32 s + ) internal view returns (bool success) { + bytes4 selector = IERC1271.isValidSignature.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + + mstore(ptr, selector) + mstore(add(ptr, 0x04), hash) + mstore(add(ptr, 0x24), 0x40) + mstore(add(ptr, 0x44), 65) + mstore(add(ptr, 0x64), r) + mstore(add(ptr, 0x84), s) + mstore8(add(ptr, 0xa4), v) + if staticcall(gas(), signer, ptr, 0xa5, 0, 0x20) { + success := and(eq(selector, mload(0)), eq(returndatasize(), 0x20)) + } + } + } + + /** + * @notice Validates a signature for a hash using EIP-1271, if `signer` is a contract. + * @dev Makes a static call to `signer` with `isValidSignature` function selector from EIP-1271. + * @param signer The address of the signer to validate against, which could be an EOA or a contract. + * @param hash The hash of the signed data. + * @param r The first 32 bytes of the signature. + * @param vs The last 32 bytes of the signature, with the last byte being the recovery id. + * @return success True if the signature is valid according to EIP-1271, false otherwise. + */ + function isValidSignature( + address signer, + bytes32 hash, + bytes32 r, + bytes32 vs + ) internal view returns (bool success) { + // (bool success, bytes memory data) = signer.staticcall(abi.encodeWithSelector(IERC1271.isValidSignature.selector, hash, abi.encodePacked(r, vs))); + // return success && data.length == 32 && abi.decode(data, (bytes4)) == IERC1271.isValidSignature.selector; + bytes4 selector = IERC1271.isValidSignature.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + + mstore(ptr, selector) + mstore(add(ptr, 0x04), hash) + mstore(add(ptr, 0x24), 0x40) + mstore(add(ptr, 0x44), 64) + mstore(add(ptr, 0x64), r) + mstore(add(ptr, 0x84), vs) + if staticcall(gas(), signer, ptr, 0xa4, 0, 0x20) { + success := and(eq(selector, mload(0)), eq(returndatasize(), 0x20)) + } + } + } + + /** + * @notice Verifies if a 65-byte signature is valid for a given hash, according to EIP-1271. + * @param signer The address of the signer to validate against, which could be an EOA or a contract. + * @param hash The hash of the signed data. + * @param r The first 32 bytes of the signature. + * @param vs The combined `v` (recovery id) and `s` component of the signature, packed into the last 32 bytes. + * @return success True if the signature is valid according to EIP-1271, false otherwise. + */ + function isValidSignature65( + address signer, + bytes32 hash, + bytes32 r, + bytes32 vs + ) internal view returns (bool success) { + // (bool success, bytes memory data) = signer.staticcall(abi.encodeWithSelector(IERC1271.isValidSignature.selector, hash, abi.encodePacked(r, vs & ~uint256(1 << 255), uint8(vs >> 255)))); + // return success && data.length == 32 && abi.decode(data, (bytes4)) == IERC1271.isValidSignature.selector; + bytes4 selector = IERC1271.isValidSignature.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + + mstore(ptr, selector) + mstore(add(ptr, 0x04), hash) + mstore(add(ptr, 0x24), 0x40) + mstore(add(ptr, 0x44), 65) + mstore(add(ptr, 0x64), r) + mstore(add(ptr, 0x84), and(vs, _COMPACT_S_MASK)) + mstore8(add(ptr, 0xa4), add(27, shr(_COMPACT_V_SHIFT, vs))) + if staticcall(gas(), signer, ptr, 0xa5, 0, 0x20) { + success := and(eq(selector, mload(0)), eq(returndatasize(), 0x20)) + } + } + } + + /** + * @notice Generates a hash compatible with Ethereum's signed message format. + * @dev Prepends the hash with Ethereum's message prefix before hashing it. + * @param hash The hash of the data to sign. + * @return res The Ethereum signed message hash. + */ + function toEthSignedMessageHash( + bytes32 hash + ) internal pure returns (bytes32 res) { + // 32 is the length in bytes of hash, enforced by the type signature above + // return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)); + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + mstore( + 0, + 0x19457468657265756d205369676e6564204d6573736167653a0a333200000000 + ) // "\x19Ethereum Signed Message:\n32" + mstore(28, hash) + res := keccak256(0, 60) + } + } + + /** + * @notice Generates an EIP-712 compliant hash. + * @dev Encodes the domain separator and the struct hash according to EIP-712. + * @param domainSeparator The EIP-712 domain separator. + * @param structHash The EIP-712 struct hash. + * @return res The EIP-712 compliant hash. + */ + function toTypedDataHash( + bytes32 domainSeparator, + bytes32 structHash + ) internal pure returns (bytes32 res) { + // return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + mstore( + ptr, + 0x1901000000000000000000000000000000000000000000000000000000000000 + ) // "\x19\x01" + mstore(add(ptr, 0x02), domainSeparator) + mstore(add(ptr, 0x22), structHash) + res := keccak256(ptr, 66) + } + } +} diff --git a/v-next/example-project-assertion/contracts/MockToken.sol b/v-next/example-project-assertion/contracts/MockToken.sol new file mode 100644 index 00000000000..bd1096e6039 --- /dev/null +++ b/v-next/example-project-assertion/contracts/MockToken.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract TokenMock is ERC20 { + constructor( + string memory name_, + string memory symbol_ + ) ERC20(name_, symbol_) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/v-next/example-project-assertion/contracts/SafeERC20.sol b/v-next/example-project-assertion/contracts/SafeERC20.sol new file mode 100644 index 00000000000..9170b6bdada --- /dev/null +++ b/v-next/example-project-assertion/contracts/SafeERC20.sol @@ -0,0 +1,550 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import "./interfaces/IDaiLikePermit.sol"; +import "./interfaces/IPermit2.sol"; +import "./interfaces/IERC7597Permit.sol"; +import "./interfaces/IWETH.sol"; +import "./libraries/RevertReasonForwarder.sol"; + +/** + * @title Implements efficient safe methods for ERC20 interface. + * @notice Compared to the standard ERC20, this implementation offers several enhancements: + * 1. more gas-efficient, providing significant savings in transaction costs. + * 2. support for different permit implementations + * 3. forceApprove functionality + * 4. support for WETH deposit and withdraw + */ +library SafeERC20 { + error SafeTransferFailed(); + error SafeTransferFromFailed(); + error ForceApproveFailed(); + error SafeIncreaseAllowanceFailed(); + error SafeDecreaseAllowanceFailed(); + error SafePermitBadLength(); + error Permit2TransferAmountTooHigh(); + + // Uniswap Permit2 address + address private constant _PERMIT2 = + 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address private constant _PERMIT2_ZKSYNC = + 0x0000000000225e31D15943971F47aD3022F714Fa; + bytes4 private constant _PERMIT_LENGTH_ERROR = 0x68275857; // SafePermitBadLength.selector + + /** + * @notice Fetches the balance of a specific ERC20 token held by an account. + * Consumes less gas then regular `ERC20.balanceOf`. + * @dev Note that the implementation does not perform dirty bits cleaning, so it is the + * responsibility of the caller to make sure that the higher 96 bits of the `account` parameter are clean. + * @param token The IERC20 token contract for which the balance will be fetched. + * @param account The address of the account whose token balance will be fetched. + * @return tokenBalance The balance of the specified ERC20 token held by the account. + */ + function safeBalanceOf( + IERC20 token, + address account + ) internal view returns (uint256 tokenBalance) { + bytes4 selector = IERC20.balanceOf.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + mstore(0x00, selector) + mstore(0x04, account) + let success := staticcall(gas(), token, 0x00, 0x24, 0x00, 0x20) + tokenBalance := mload(0) + + if or(iszero(success), lt(returndatasize(), 0x20)) { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize()) + revert(ptr, returndatasize()) + } + } + } + + /** + * @notice Attempts to safely transfer tokens from one address to another. + * @dev If permit2 is true, uses the Permit2 standard; otherwise uses the standard ERC20 transferFrom. + * Either requires `true` in return data, or requires target to be smart-contract and empty return data. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `from` and `to` parameters are clean. + * @param token The IERC20 token contract from which the tokens will be transferred. + * @param from The address from which the tokens will be transferred. + * @param to The address to which the tokens will be transferred. + * @param amount The amount of tokens to transfer. + * @param permit2 If true, uses the Permit2 standard for the transfer; otherwise uses the standard ERC20 transferFrom. + */ + function safeTransferFromUniversal( + IERC20 token, + address from, + address to, + uint256 amount, + bool permit2 + ) internal { + if (permit2) { + safeTransferFromPermit2(token, from, to, amount); + } else { + safeTransferFrom(token, from, to, amount); + } + } + + /** + * @notice Attempts to safely transfer tokens from one address to another using the ERC20 standard. + * @dev Either requires `true` in return data, or requires target to be smart-contract and empty return data. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `from` and `to` parameters are clean. + * @param token The IERC20 token contract from which the tokens will be transferred. + * @param from The address from which the tokens will be transferred. + * @param to The address to which the tokens will be transferred. + * @param amount The amount of tokens to transfer. + */ + function safeTransferFrom( + IERC20 token, + address from, + address to, + uint256 amount + ) internal { + bytes4 selector = token.transferFrom.selector; + bool success; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let data := mload(0x40) + + mstore(data, selector) + mstore(add(data, 0x04), from) + mstore(add(data, 0x24), to) + mstore(add(data, 0x44), amount) + success := call(gas(), token, 0, data, 0x64, 0x0, 0x20) + if success { + switch returndatasize() + case 0 { + success := gt(extcodesize(token), 0) + } + default { + success := and(gt(returndatasize(), 31), eq(mload(0), 1)) + } + } + } + if (!success) revert SafeTransferFromFailed(); + } + + /** + * @notice Attempts to safely transfer tokens from one address to another using the Permit2 standard. + * @dev Either requires `true` in return data, or requires target to be smart-contract and empty return data. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `from` and `to` parameters are clean. + * @param token The IERC20 token contract from which the tokens will be transferred. + * @param from The address from which the tokens will be transferred. + * @param to The address to which the tokens will be transferred. + * @param amount The amount of tokens to transfer. + */ + function safeTransferFromPermit2( + IERC20 token, + address from, + address to, + uint256 amount + ) internal { + if (amount > type(uint160).max) revert Permit2TransferAmountTooHigh(); + address permit2 = _getPermit2Address(); + bytes4 selector = IPermit2.transferFrom.selector; + bool success; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let data := mload(0x40) + + mstore(data, selector) + mstore(add(data, 0x04), from) + mstore(add(data, 0x24), to) + mstore(add(data, 0x44), amount) + mstore(add(data, 0x64), token) + success := call(gas(), permit2, 0, data, 0x84, 0x0, 0x0) + if success { + success := gt(extcodesize(permit2), 0) + } + } + if (!success) revert SafeTransferFromFailed(); + } + + /** + * @notice Attempts to safely transfer tokens to another address. + * @dev Either requires `true` in return data, or requires target to be smart-contract and empty return data. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `to` parameter are clean. + * @param token The IERC20 token contract from which the tokens will be transferred. + * @param to The address to which the tokens will be transferred. + * @param amount The amount of tokens to transfer. + */ + function safeTransfer(IERC20 token, address to, uint256 amount) internal { + if (!_makeCall(token, token.transfer.selector, to, amount)) { + revert SafeTransferFailed(); + } + } + + /** + * @notice Attempts to approve a spender to spend a certain amount of tokens. + * @dev If `approve(from, to, amount)` fails, it tries to set the allowance to zero, and retries the `approve` call. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `spender` parameter are clean. + * @param token The IERC20 token contract on which the call will be made. + * @param spender The address which will spend the funds. + * @param value The amount of tokens to be spent. + */ + function forceApprove(IERC20 token, address spender, uint256 value) internal { + if (!_makeCall(token, token.approve.selector, spender, value)) { + if ( + !_makeCall(token, token.approve.selector, spender, 0) || + !_makeCall(token, token.approve.selector, spender, value) + ) { + revert ForceApproveFailed(); + } + } + } + + /** + * @notice Safely increases the allowance of a spender. + * @dev Increases with safe math check. Checks if the increased allowance will overflow, if yes, then it reverts the transaction. + * Then uses `forceApprove` to increase the allowance. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `spender` parameter are clean. + * @param token The IERC20 token contract on which the call will be made. + * @param spender The address which will spend the funds. + * @param value The amount of tokens to increase the allowance by. + */ + function safeIncreaseAllowance( + IERC20 token, + address spender, + uint256 value + ) internal { + uint256 allowance = token.allowance(address(this), spender); + if (value > type(uint256).max - allowance) + revert SafeIncreaseAllowanceFailed(); + forceApprove(token, spender, allowance + value); + } + + /** + * @notice Safely decreases the allowance of a spender. + * @dev Decreases with safe math check. Checks if the decreased allowance will underflow, if yes, then it reverts the transaction. + * Then uses `forceApprove` to increase the allowance. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `spender` parameter are clean. + * @param token The IERC20 token contract on which the call will be made. + * @param spender The address which will spend the funds. + * @param value The amount of tokens to decrease the allowance by. + */ + function safeDecreaseAllowance( + IERC20 token, + address spender, + uint256 value + ) internal { + uint256 allowance = token.allowance(address(this), spender); + if (value > allowance) revert SafeDecreaseAllowanceFailed(); + forceApprove(token, spender, allowance - value); + } + + /** + * @notice Attempts to execute the `permit` function on the provided token with the sender and contract as parameters. + * Permit type is determined automatically based on permit calldata (IERC20Permit, IDaiLikePermit, and IPermit2). + * @dev Wraps `tryPermit` function and forwards revert reason if permit fails. + * @param token The IERC20 token to execute the permit function on. + * @param permit The permit data to be used in the function call. + */ + function safePermit(IERC20 token, bytes calldata permit) internal { + if (!tryPermit(token, msg.sender, address(this), permit)) + RevertReasonForwarder.reRevert(); + } + + /** + * @notice Attempts to execute the `permit` function on the provided token with custom owner and spender parameters. + * Permit type is determined automatically based on permit calldata (IERC20Permit, IDaiLikePermit, and IPermit2). + * @dev Wraps `tryPermit` function and forwards revert reason if permit fails. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `owner` and `spender` parameters are clean. + * @param token The IERC20 token to execute the permit function on. + * @param owner The owner of the tokens for which the permit is made. + * @param spender The spender allowed to spend the tokens by the permit. + * @param permit The permit data to be used in the function call. + */ + function safePermit( + IERC20 token, + address owner, + address spender, + bytes calldata permit + ) internal { + if (!tryPermit(token, owner, spender, permit)) + RevertReasonForwarder.reRevert(); + } + + /** + * @notice Attempts to execute the `permit` function on the provided token with the sender and contract as parameters. + * @dev Invokes `tryPermit` with sender as owner and contract as spender. + * @param token The IERC20 token to execute the permit function on. + * @param permit The permit data to be used in the function call. + * @return success Returns true if the permit function was successfully executed, false otherwise. + */ + function tryPermit( + IERC20 token, + bytes calldata permit + ) internal returns (bool success) { + return tryPermit(token, msg.sender, address(this), permit); + } + + /** + * @notice The function attempts to call the permit function on a given ERC20 token. + * @dev The function is designed to support a variety of permit functions, namely: IERC20Permit, IDaiLikePermit, IERC7597Permit and IPermit2. + * It accommodates both Compact and Full formats of these permit types. + * Please note, it is expected that the `expiration` parameter for the compact Permit2 and the `deadline` parameter + * for the compact Permit are to be incremented by one before invoking this function. This approach is motivated by + * gas efficiency considerations; as the unlimited expiration period is likely to be the most common scenario, and + * zeros are cheaper to pass in terms of gas cost. Thus, callers should increment the expiration or deadline by one + * before invocation for optimized performance. + * Note that the implementation does not perform dirty bits cleaning, so it is the responsibility of + * the caller to make sure that the higher 96 bits of the `owner` and `spender` parameters are clean. + * @param token The address of the ERC20 token on which to call the permit function. + * @param owner The owner of the tokens. This address should have signed the off-chain permit. + * @param spender The address which will be approved for transfer of tokens. + * @param permit The off-chain permit data, containing different fields depending on the type of permit function. + * @return success A boolean indicating whether the permit call was successful. + */ + function tryPermit( + IERC20 token, + address owner, + address spender, + bytes calldata permit + ) internal returns (bool success) { + address permit2 = _getPermit2Address(); + // load function selectors for different permit standards + bytes4 permitSelector = IERC20Permit.permit.selector; + bytes4 daiPermitSelector = IDaiLikePermit.permit.selector; + bytes4 permit2Selector = IPermit2.permit.selector; + bytes4 erc7597PermitSelector = IERC7597Permit.permit.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + + // Switch case for different permit lengths, indicating different permit standards + switch permit.length + // Compact IERC20Permit + case 100 { + mstore(ptr, permitSelector) // store selector + mstore(add(ptr, 0x04), owner) // store owner + mstore(add(ptr, 0x24), spender) // store spender + + // Compact IERC20Permit.permit(uint256 value, uint32 deadline, uint256 r, uint256 vs) + { + // stack too deep + let deadline := shr(224, calldataload(add(permit.offset, 0x20))) // loads permit.offset 0x20..0x23 + let vs := calldataload(add(permit.offset, 0x44)) // loads permit.offset 0x44..0x63 + + calldatacopy(add(ptr, 0x44), permit.offset, 0x20) // store value = copy permit.offset 0x00..0x19 + mstore(add(ptr, 0x64), sub(deadline, 1)) // store deadline = deadline - 1 + mstore(add(ptr, 0x84), add(27, shr(255, vs))) // store v = most significant bit of vs + 27 (27 or 28) + calldatacopy(add(ptr, 0xa4), add(permit.offset, 0x24), 0x20) // store r = copy permit.offset 0x24..0x43 + mstore(add(ptr, 0xc4), shr(1, shl(1, vs))) // store s = vs without most significant bit + } + // IERC20Permit.permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + success := call(gas(), token, 0, ptr, 0xe4, 0, 0) + } + // Compact IDaiLikePermit + case 72 { + mstore(ptr, daiPermitSelector) // store selector + mstore(add(ptr, 0x04), owner) // store owner + mstore(add(ptr, 0x24), spender) // store spender + + // Compact IDaiLikePermit.permit(uint32 nonce, uint32 expiry, uint256 r, uint256 vs) + { + // stack too deep + let expiry := shr(224, calldataload(add(permit.offset, 0x04))) // loads permit.offset 0x04..0x07 + let vs := calldataload(add(permit.offset, 0x28)) // loads permit.offset 0x28..0x47 + + mstore(add(ptr, 0x44), shr(224, calldataload(permit.offset))) // store nonce = copy permit.offset 0x00..0x03 + mstore(add(ptr, 0x64), sub(expiry, 1)) // store expiry = expiry - 1 + mstore(add(ptr, 0x84), true) // store allowed = true + mstore(add(ptr, 0xa4), add(27, shr(255, vs))) // store v = most significant bit of vs + 27 (27 or 28) + calldatacopy(add(ptr, 0xc4), add(permit.offset, 0x08), 0x20) // store r = copy permit.offset 0x08..0x27 + mstore(add(ptr, 0xe4), shr(1, shl(1, vs))) // store s = vs without most significant bit + } + // IDaiLikePermit.permit(address holder, address spender, uint256 nonce, uint256 expiry, bool allowed, uint8 v, bytes32 r, bytes32 s) + success := call(gas(), token, 0, ptr, 0x104, 0, 0) + } + // IERC20Permit + case 224 { + mstore(ptr, permitSelector) + calldatacopy(add(ptr, 0x04), permit.offset, permit.length) // copy permit calldata + // IERC20Permit.permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + success := call(gas(), token, 0, ptr, 0xe4, 0, 0) + } + // IDaiLikePermit + case 256 { + mstore(ptr, daiPermitSelector) + calldatacopy(add(ptr, 0x04), permit.offset, permit.length) // copy permit calldata + // IDaiLikePermit.permit(address holder, address spender, uint256 nonce, uint256 expiry, bool allowed, uint8 v, bytes32 r, bytes32 s) + success := call(gas(), token, 0, ptr, 0x104, 0, 0) + } + // Compact IPermit2 + case 96 { + // Compact IPermit2.permit(uint160 amount, uint32 expiration, uint32 nonce, uint32 sigDeadline, uint256 r, uint256 vs) + mstore(ptr, permit2Selector) // store selector + mstore(add(ptr, 0x04), owner) // store owner + mstore(add(ptr, 0x24), token) // store token + + calldatacopy(add(ptr, 0x50), permit.offset, 0x14) // store amount = copy permit.offset 0x00..0x13 + // and(0xffffffffffff, ...) - conversion to uint48 + mstore( + add(ptr, 0x64), + and( + 0xffffffffffff, + sub(shr(224, calldataload(add(permit.offset, 0x14))), 1) + ) + ) // store expiration = ((permit.offset 0x14..0x17 - 1) & 0xffffffffffff) + mstore(add(ptr, 0x84), shr(224, calldataload(add(permit.offset, 0x18)))) // store nonce = copy permit.offset 0x18..0x1b + mstore(add(ptr, 0xa4), spender) // store spender + // and(0xffffffffffff, ...) - conversion to uint48 + mstore( + add(ptr, 0xc4), + and( + 0xffffffffffff, + sub(shr(224, calldataload(add(permit.offset, 0x1c))), 1) + ) + ) // store sigDeadline = ((permit.offset 0x1c..0x1f - 1) & 0xffffffffffff) + mstore(add(ptr, 0xe4), 0x100) // store offset = 256 + mstore(add(ptr, 0x104), 0x40) // store length = 64 + calldatacopy(add(ptr, 0x124), add(permit.offset, 0x20), 0x20) // store r = copy permit.offset 0x20..0x3f + calldatacopy(add(ptr, 0x144), add(permit.offset, 0x40), 0x20) // store vs = copy permit.offset 0x40..0x5f + // IPermit2.permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) + success := call(gas(), permit2, 0, ptr, 0x164, 0, 0) + } + // IPermit2 + case 352 { + mstore(ptr, permit2Selector) + calldatacopy(add(ptr, 0x04), permit.offset, permit.length) // copy permit calldata + // IPermit2.permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) + success := call(gas(), permit2, 0, ptr, 0x164, 0, 0) + } + // Dynamic length + default { + mstore(ptr, erc7597PermitSelector) + calldatacopy(add(ptr, 0x04), permit.offset, permit.length) // copy permit calldata + // IERC7597Permit.permit(address owner, address spender, uint256 value, uint256 deadline, bytes memory signature) + success := call(gas(), token, 0, ptr, add(permit.length, 4), 0, 0) + } + } + } + + /** + * @dev Executes a low level call to a token contract, making it resistant to reversion and erroneous boolean returns. + * @param token The IERC20 token contract on which the call will be made. + * @param selector The function signature that is to be called on the token contract. + * @param to The address to which the token amount will be transferred. + * @param amount The token amount to be transferred. + * @return success A boolean indicating if the call was successful. Returns 'true' on success and 'false' on failure. + * In case of success but no returned data, validates that the contract code exists. + * In case of returned data, ensures that it's a boolean `true`. + */ + function _makeCall( + IERC20 token, + bytes4 selector, + address to, + uint256 amount + ) private returns (bool success) { + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let data := mload(0x40) + + mstore(data, selector) + mstore(add(data, 0x04), to) + mstore(add(data, 0x24), amount) + success := call(gas(), token, 0, data, 0x44, 0x0, 0x20) + if success { + switch returndatasize() + case 0 { + success := gt(extcodesize(token), 0) + } + default { + success := and(gt(returndatasize(), 31), eq(mload(0), 1)) + } + } + } + } + + /** + * @notice Safely deposits a specified amount of Ether into the IWETH contract. Consumes less gas then regular `IWETH.deposit`. + * @param weth The IWETH token contract. + * @param amount The amount of Ether to deposit into the IWETH contract. + */ + function safeDeposit(IWETH weth, uint256 amount) internal { + bytes4 selector = IWETH.deposit.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + mstore(0, selector) + if iszero(call(gas(), weth, amount, 0, 4, 0, 0)) { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize()) + revert(ptr, returndatasize()) + } + } + } + + /** + * @notice Safely withdraws a specified amount of wrapped Ether from the IWETH contract. Consumes less gas then regular `IWETH.withdraw`. + * @dev Uses inline assembly to interact with the IWETH contract. + * @param weth The IWETH token contract. + * @param amount The amount of wrapped Ether to withdraw from the IWETH contract. + */ + function safeWithdraw(IWETH weth, uint256 amount) internal { + bytes4 selector = IWETH.withdraw.selector; + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + mstore(0, selector) + mstore(4, amount) + if iszero(call(gas(), weth, 0, 0, 0x24, 0, 0)) { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize()) + revert(ptr, returndatasize()) + } + } + } + + /** + * @notice Safely withdraws a specified amount of wrapped Ether from the IWETH contract to a specified recipient. + * Consumes less gas then regular `IWETH.withdraw`. + * @param weth The IWETH token contract. + * @param amount The amount of wrapped Ether to withdraw from the IWETH contract. + * @param to The recipient of the withdrawn Ether. + */ + function safeWithdrawTo(IWETH weth, uint256 amount, address to) internal { + safeWithdraw(weth, amount); + if (to != address(this)) { + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + if iszero(call(gas(), to, amount, 0, 0, 0, 0)) { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize()) + revert(ptr, returndatasize()) + } + } + } + } + + function _getPermit2Address() private view returns (address permit2) { + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + switch chainid() + case 324 { + // zksync mainnet + permit2 := _PERMIT2_ZKSYNC + } + case 300 { + // zksync testnet + permit2 := _PERMIT2_ZKSYNC + } + case 260 { + // zksync fork network + permit2 := _PERMIT2_ZKSYNC + } + default { + permit2 := _PERMIT2 + } + } + } +} diff --git a/v-next/example-project-assertion/contracts/Signature.sol b/v-next/example-project-assertion/contracts/Signature.sol new file mode 100644 index 00000000000..855e0817c56 --- /dev/null +++ b/v-next/example-project-assertion/contracts/Signature.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.28; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SafeERC20, IERC20} from "./SafeERC20.sol"; +import {ECDSA} from "./ECDSA.sol"; + +import {TokenMock} from "./MockToken.sol"; + +// import {ISignatureMerkleDrop128} from "./interfaces/ISignatureMerkleDrop128.sol"; + +error InvalidProof(); +error DropAlreadyClaimed(); + +/** + * @title SignatureMerkleDrop128 + * @author 1inch Network + * @notice A gas-optimized contract for distributing tokens via 128-bit Merkle tree proofs with signature verification + * @dev This contract uses 128-bit (16 bytes) Merkle tree nodes for gas optimization and requires + * signature verification for claims. Each claim can only be made once, tracked via a bitmap for gas efficiency. + */ +contract SignatureMerkleDrop128 is Ownable { + using Address for address payable; + using SafeERC20 for IERC20; + + /* solhint-disable immutable-vars-naming */ + /// @notice The ERC20 token being distributed + address public immutable token; + /// @notice The 128-bit Merkle root for the distribution + bytes16 public immutable merkleRoot; + /// @notice The depth of the Merkle tree + uint256 public immutable depth; + /* solhint-enable immutable-vars-naming */ + + /// @notice Bitmap tracking claimed indices (packed for gas efficiency) + // This is a packed array of booleans. + mapping(uint256 => uint256) private _claimedBitMap; + + /// @notice Estimated gas cost for claim operation used in cashback calculation + uint256 private constant _CLAIM_GAS_COST = 60000; + + /** + * @notice Allows contract to receive ETH for gas cashback functionality + */ + receive() external payable {} // solhint-disable-line no-empty-blocks + + /** + * @notice Constructs the SignatureMerkleDrop128 contract + * @param token_ The address of the ERC20 token to be distributed + * @param merkleRoot_ The 128-bit Merkle root of the distribution + * @param depth_ The depth of the Merkle tree + */ + constructor( + address token_, + bytes16 merkleRoot_, + uint256 depth_ + ) Ownable(msg.sender) { + token = token_; + merkleRoot = merkleRoot_; + depth = depth_; + } + + /** + * @notice Claims tokens for a receiver using a Merkle proof and signature + * @dev The signature must be from the account that is part of the Merkle tree. + * Includes gas cashback functionality if ETH is sent with the transaction. + * @param receiver The address that will receive the tokens + * @param amount The amount of tokens to claim + * @param merkleProof The Merkle proof verifying the claim (must be a multiple of 16 bytes) + * @param signature The signature from the account authorized in the Merkle tree + */ + function claim( + address receiver, + uint256 amount, + bytes calldata merkleProof, + bytes calldata signature + ) external payable { + bytes32 signedHash = ECDSA.toEthSignedMessageHash( + keccak256(abi.encodePacked(receiver)) + ); + address account = ECDSA.recover(signedHash, signature); + // Verify the merkle proof. + bytes16 node = bytes16(keccak256(abi.encodePacked(account, amount))); + (bool valid, uint256 index) = _verifyAsm(merkleProof, merkleRoot, node); + if (!valid) revert InvalidProof(); + _invalidate(index); + IERC20(token).safeTransfer(receiver, amount); + if (msg.value > 0) { + payable(receiver).sendValue(msg.value); + } + _cashback(); + } + + /** + * @notice Verifies a Merkle proof against a specified root + * @param proof The Merkle proof to verify (must be a multiple of 16 bytes) + * @param root The 128-bit Merkle root to verify against + * @param leaf The 128-bit leaf node to verify + * @return valid True if the proof is valid, false otherwise + * @return index The index of the leaf in the Merkle tree + */ + function verify( + bytes calldata proof, + bytes16 root, + bytes16 leaf + ) external view returns (bool valid, uint256 index) { + return _verifyAsm(proof, root, leaf); + } + + /** + * @notice Verifies a Merkle proof against the contract's merkleRoot + * @param proof The Merkle proof to verify (must be a multiple of 16 bytes) + * @param leaf The 128-bit leaf node to verify + * @return valid True if the proof is valid, false otherwise + * @return index The index of the leaf in the Merkle tree + */ + function verify( + bytes calldata proof, + bytes16 leaf + ) external view returns (bool valid, uint256 index) { + return _verifyAsm(proof, merkleRoot, leaf); + } + + /** + * @notice Checks if a claim at a specific index has already been made + * @param index The index in the Merkle tree to check + * @return True if the claim has been made, false otherwise + */ + function isClaimed(uint256 index) external view returns (bool) { + uint256 claimedWordIndex = index / 256; + uint256 claimedBitIndex = index % 256; + uint256 claimedWord = _claimedBitMap[claimedWordIndex]; + uint256 mask = (1 << claimedBitIndex); + return claimedWord & mask == mask; + } + + /** + * @notice Provides gas cashback to the transaction originator + * @dev Sends ETH back to tx.origin to compensate for gas costs, capped at basefee * _CLAIM_GAS_COST + */ + function _cashback() private { + uint256 balance = address(this).balance; + if (balance > 0) { + // solhint-disable-next-line avoid-tx-origin + payable(tx.origin).sendValue( + Math.min(block.basefee * _CLAIM_GAS_COST, balance) + ); + } + } + + /** + * @notice Marks a claim index as used in the bitmap + * @dev Reverts if the index has already been claimed + * @param index The index to mark as claimed + */ + function _invalidate(uint256 index) private { + uint256 claimedWordIndex = index >> 8; + uint256 claimedBitIndex = index & 0xff; + uint256 claimedWord = _claimedBitMap[claimedWordIndex]; + uint256 newClaimedWord = claimedWord | (1 << claimedBitIndex); + if (claimedWord == newClaimedWord) revert DropAlreadyClaimed(); + _claimedBitMap[claimedWordIndex] = newClaimedWord; + } + + /** + * @notice Verifies a 128-bit Merkle proof using assembly for gas optimization + * @dev Uses sorted pairs when hashing and calculates the leaf index during verification + * @param proof The Merkle proof to verify (must be a multiple of 16 bytes) + * @param root The 128-bit Merkle root to verify against + * @param leaf The 128-bit leaf node to verify + * @return valid True if the proof is valid, false otherwise + * @return index The calculated index of the leaf in the Merkle tree + */ + function _verifyAsm( + bytes calldata proof, + bytes16 root, + bytes16 leaf + ) private view returns (bool valid, uint256 index) { + /// @solidity memory-safe-assembly + assembly { + // solhint-disable-line no-inline-assembly + let ptr := proof.offset + let mask := 1 + + for { + let end := add(ptr, proof.length) + } lt(ptr, end) { + ptr := add(ptr, 0x10) + } { + let node := calldataload(ptr) + + switch lt(leaf, node) + case 1 { + mstore(0x00, leaf) + mstore(0x10, node) + } + default { + mstore(0x00, node) + mstore(0x10, leaf) + index := or(mask, index) + } + + leaf := keccak256(0x00, 0x20) + mask := shl(1, mask) + } + + valid := iszero(shr(128, xor(root, leaf))) + } + unchecked { + index <<= depth - proof.length / 16; + } + } + + /** + * @notice Allows owner to rescue stuck funds (ETH or ERC20 tokens) + * @dev Only callable by the contract owner + * @param token_ The token address to rescue (use address(0) for ETH) + * @param amount The amount to rescue + */ + function rescueFunds(address token_, uint256 amount) external onlyOwner { + if (token_ == address(0)) { + payable(msg.sender).sendValue(amount); + } else { + IERC20(token_).safeTransfer(msg.sender, amount); + } + } +} diff --git a/v-next/example-project-assertion/contracts/interfaces/IDaiLikePermit.sol b/v-next/example-project-assertion/contracts/interfaces/IDaiLikePermit.sol new file mode 100644 index 00000000000..3c3d1250cb0 --- /dev/null +++ b/v-next/example-project-assertion/contracts/interfaces/IDaiLikePermit.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @title IDaiLikePermit + * @dev Interface for Dai-like permit function allowing token spending via signatures. + */ +interface IDaiLikePermit { + /** + * @notice Approves spending of tokens via off-chain signatures. + * @param holder Token holder's address. + * @param spender Spender's address. + * @param nonce Current nonce of the holder. + * @param expiry Time when the permit expires. + * @param allowed True to allow, false to disallow spending. + * @param v, r, s Signature components. + */ + function permit( + address holder, + address spender, + uint256 nonce, + uint256 expiry, + bool allowed, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} diff --git a/v-next/example-project-assertion/contracts/interfaces/IERC7597Permit.sol b/v-next/example-project-assertion/contracts/interfaces/IERC7597Permit.sol new file mode 100644 index 00000000000..b82fd398cbe --- /dev/null +++ b/v-next/example-project-assertion/contracts/interfaces/IERC7597Permit.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @title IERC7597Permit + * @dev A new extension for ERC-2612 permit, which has already been added to USDC v2.2. + */ +interface IERC7597Permit { + /** + * @notice Update allowance with a signed permit. + * @dev Signature bytes can be used for both EOA wallets and contract wallets. + * @param owner Token owner's address (Authorizer). + * @param spender Spender's address. + * @param value Amount of allowance. + * @param deadline The time at which the signature expires (unixtime). + * @param signature Unstructured bytes signature signed by an EOA wallet or a contract wallet. + */ + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + bytes memory signature + ) external; +} diff --git a/v-next/example-project-assertion/contracts/interfaces/IPermit2.sol b/v-next/example-project-assertion/contracts/interfaces/IPermit2.sol new file mode 100644 index 00000000000..18a707f43b4 --- /dev/null +++ b/v-next/example-project-assertion/contracts/interfaces/IPermit2.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @title IPermit2 + * @dev Interface for a flexible permit system that extends ERC20 tokens to support permits in tokens lacking native permit functionality. + */ +interface IPermit2 { + /** + * @dev Struct for holding permit details. + * @param token ERC20 token address for which the permit is issued. + * @param amount The maximum amount allowed to spend. + * @param expiration Timestamp until which the permit is valid. + * @param nonce An incrementing value for each signature, unique per owner, token, and spender. + */ + struct PermitDetails { + address token; + uint160 amount; + uint48 expiration; + uint48 nonce; + } + + /** + * @dev Struct for a single token allowance permit. + * @param details Permit details including token, amount, expiration, and nonce. + * @param spender Address authorized to spend the tokens. + * @param sigDeadline Deadline for the permit signature, ensuring timeliness of the permit. + */ + struct PermitSingle { + PermitDetails details; + address spender; + uint256 sigDeadline; + } + + /** + * @dev Struct for packed allowance data to optimize storage. + * @param amount Amount allowed. + * @param expiration Permission expiry timestamp. + * @param nonce Unique incrementing value for tracking allowances. + */ + struct PackedAllowance { + uint160 amount; + uint48 expiration; + uint48 nonce; + } + + /** + * @notice Executes a token transfer from one address to another. + * @param user The token owner's address. + * @param spender The address authorized to spend the tokens. + * @param amount The amount of tokens to transfer. + * @param token The address of the token being transferred. + */ + function transferFrom( + address user, + address spender, + uint160 amount, + address token + ) external; + + /** + * @notice Issues a permit for spending tokens via a signed authorization. + * @param owner The token owner's address. + * @param permitSingle Struct containing the permit details. + * @param signature The signature proving the owner authorized the permit. + */ + function permit( + address owner, + PermitSingle memory permitSingle, + bytes calldata signature + ) external; + + /** + * @notice Retrieves the allowance details between a token owner and spender. + * @param user The token owner's address. + * @param token The token address. + * @param spender The spender's address. + * @return The packed allowance details. + */ + function allowance( + address user, + address token, + address spender + ) external view returns (PackedAllowance memory); + + /** + * @notice Approves the spender to use up to amount of the specified token up until the expiration + * @param token The token to approve + * @param spender The spender address to approve + * @param amount The approved amount of the token + * @param expiration The timestamp at which the approval is no longer valid + * @dev The packed allowance also holds a nonce, which will stay unchanged in approve + * @dev Setting amount to type(uint160).max sets an unlimited approval + */ + function approve( + address token, + address spender, + uint160 amount, + uint48 expiration + ) external; +} diff --git a/v-next/example-project-assertion/contracts/interfaces/IWETH.sol b/v-next/example-project-assertion/contracts/interfaces/IWETH.sol new file mode 100644 index 00000000000..bff3a6a6947 --- /dev/null +++ b/v-next/example-project-assertion/contracts/interfaces/IWETH.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title IWETH + * @dev Interface for wrapper as WETH-like token. + */ +interface IWETH is IERC20 { + /** + * @notice Emitted when Ether is deposited to get wrapper tokens. + */ + event Deposit(address indexed dst, uint256 wad); + + /** + * @notice Emitted when wrapper tokens is withdrawn as Ether. + */ + event Withdrawal(address indexed src, uint256 wad); + + /** + * @notice Deposit Ether to get wrapper tokens. + */ + function deposit() external payable; + + /** + * @notice Withdraw wrapped tokens as Ether. + * @param amount Amount of wrapped tokens to withdraw. + */ + function withdraw(uint256 amount) external; +} diff --git a/v-next/example-project-assertion/contracts/libraries/RevertReasonForwarder.sol b/v-next/example-project-assertion/contracts/libraries/RevertReasonForwarder.sol new file mode 100644 index 00000000000..eb1802daaac --- /dev/null +++ b/v-next/example-project-assertion/contracts/libraries/RevertReasonForwarder.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @title RevertReasonForwarder + * @notice Provides utilities for forwarding and retrieving revert reasons from failed external calls. + */ +library RevertReasonForwarder { + /** + * @dev Forwards the revert reason from the latest external call. + * This method allows propagating the revert reason of a failed external call to the caller. + */ + function reRevert() internal pure { + // bubble up revert reason from latest external call + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize()) + revert(ptr, returndatasize()) + } + } + + /** + * @dev Retrieves the revert reason from the latest external call. + * This method enables capturing the revert reason of a failed external call for inspection or processing. + * @return reason The latest external call revert reason. + */ + function reReason() internal pure returns (bytes memory reason) { + assembly ("memory-safe") { + // solhint-disable-line no-inline-assembly + reason := mload(0x40) + let length := returndatasize() + mstore(reason, length) + returndatacopy(add(reason, 0x20), 0, length) + mstore(0x40, add(reason, add(0x20, length))) + } + } +} diff --git a/v-next/example-project-assertion/hardhat.config.ts b/v-next/example-project-assertion/hardhat.config.ts new file mode 100644 index 00000000000..7092b8529ea --- /dev/null +++ b/v-next/example-project-assertion/hardhat.config.ts @@ -0,0 +1,38 @@ +import hardhatToolboxMochaEthersPlugin from "@nomicfoundation/hardhat-toolbox-mocha-ethers"; +import { configVariable, defineConfig } from "hardhat/config"; + +export default defineConfig({ + plugins: [hardhatToolboxMochaEthersPlugin], + solidity: { + profiles: { + default: { + version: "0.8.28", + }, + production: { + version: "0.8.28", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + }, + }, + networks: { + hardhatMainnet: { + type: "edr-simulated", + chainType: "l1", + }, + hardhatOp: { + type: "edr-simulated", + chainType: "op", + }, + sepolia: { + type: "http", + chainType: "l1", + url: configVariable("SEPOLIA_RPC_URL"), + accounts: [configVariable("SEPOLIA_PRIVATE_KEY")], + }, + }, +}); diff --git a/v-next/example-project-assertion/package.json b/v-next/example-project-assertion/package.json new file mode 100644 index 00000000000..f084f1b85de --- /dev/null +++ b/v-next/example-project-assertion/package.json @@ -0,0 +1,46 @@ +{ + "name": "@nomicfoundation/example-project-assertion", + "private": true, + "version": "3.0.1", + "description": "A temporary example project", + "homepage": "https://github.com/nomicfoundation/hardhat/tree/v-next/v-next/example-project", + "repository": { + "type": "git", + "url": "https://github.com/NomicFoundation/hardhat", + "directory": "v-next/example-project" + }, + "author": "Nomic Foundation", + "license": "MIT", + "type": "module", + "scripts": { + "lint": "pnpm prettier --check", + "lint:fix": "pnpm prettier --write", + "prettier": "prettier \"**/*.{ts,js,md,json}\"", + "prebuild": "pnpm run --dir ../hardhat-ignition-viem build", + "build": "tsc --build .", + "clean": "rimraf dist artifacts cache ignition/deployments types", + "pretest": "pnpm build && pnpm install", + "test": "hardhat test nodejs && hardhat test mocha" + }, + "devDependencies": { + "hardhat": "workspace:^3.0.15", + "@metamask/eth-sig-util": "^8.2.0", + "@nomicfoundation/hardhat-toolbox-mocha-ethers": "workspace:^3.0.1", + "@nomicfoundation/hardhat-errors": "workspace:^3.0.5", + "@openzeppelin/contracts": "5.1.0", + "@types/chai": "^4.2.0", + "@types/mocha": ">=10.0.10", + "@types/node": "^20.14.9", + "@uniswap/v4-core": "1.0.2", + "permit2": "uniswap/permit2#cc56ad0f3439c502c246fc5cfcc3db92bb8b7219", + "chai": "^5.1.2", + "ethers": "^6.14.0", + "forge-std": "foundry-rs/forge-std#v1.9.4", + "mocha": "^11.0.0", + "prettier": "3.2.5", + "rimraf": "^5.0.5", + "typescript": "~5.8.0", + "keccak256": "^1.0.6", + "merkletreejs": "^0.6.0" + } +} diff --git a/v-next/example-project-assertion/scripts/test-closed-connection.ts b/v-next/example-project-assertion/scripts/test-closed-connection.ts new file mode 100644 index 00000000000..46ee011f6fb --- /dev/null +++ b/v-next/example-project-assertion/scripts/test-closed-connection.ts @@ -0,0 +1,21 @@ +import { network } from "hardhat"; + +const connection = await network.connect({}); + +const beforeCloseBlock = await connection.ethers.provider.getBlock(0); + +if (beforeCloseBlock !== null) { + console.log("Before close - Block found: ", beforeCloseBlock.number); +} else { + throw new Error("Before close -Block returned is null"); +} + +await connection.close(); + +const afterCloseBlock = await connection.ethers.provider.getBlock(0); + +if (afterCloseBlock !== null) { + console.log("After close - Block found: ", afterCloseBlock.number); +} else { + throw new Error("After close - Block returned is null"); +} diff --git a/v-next/example-project-assertion/test/Assertion.ts b/v-next/example-project-assertion/test/Assertion.ts new file mode 100644 index 00000000000..e1edaefd5c8 --- /dev/null +++ b/v-next/example-project-assertion/test/Assertion.ts @@ -0,0 +1,133 @@ +import { expect } from "chai"; +import { network } from "hardhat"; +import keccak256 from "keccak256"; +import { personalSign } from "@metamask/eth-sig-util"; +import type { Signer, Contract } from "ethers"; +import { MerkleTree } from "merkletreejs"; +const { ethers, networkHelpers } = await network.connect(); +const { loadFixture } = networkHelpers; + +interface AccountWithDropValue { + account: Signer; + amount: number; +} + +function keccak128(input: Buffer | string): Buffer { + return keccak256(input).slice(0, 16); +} + +describe("Hardhat3 test", function () { + async function deployContractsFixture() { + const [owner, alice, bob, carol, dan] = await ethers.getSigners(); + const token = (await ethers.deployContract("TokenMock", [ + "1INCH Token", + "1INCH", + ])) as unknown as Contract; + + await Promise.all([alice, bob, carol, dan].map((w) => token.mint(w, 1n))); + + const accountWithDropValues: AccountWithDropValue[] = [ + { account: owner, amount: 1 }, + { account: alice, amount: 1 }, + ]; + + const elements = await Promise.all( + accountWithDropValues.map(async (w) => { + const address = await w.account.getAddress(); + return ( + "0x" + + address.slice(2) + + BigInt(w.amount).toString(16).padStart(64, "0") + ); + }) + ); + const hashedElements = elements.map((elem) => + MerkleTree.bufferToHex(keccak128(elem)) + ); + const tree = new MerkleTree(elements, keccak128, { + hashLeaves: true, + sort: true, + }); + const root = tree.getHexRoot(); + const leaves = tree.getHexLeaves(); + const proofs = leaves + .map(tree.getHexProof, tree) + .map((proof) => "0x" + proof.map((p) => p.slice(2)).join("")); + + const SignatureMerkleDrop128Factory = await ethers.getContractFactory( + "SignatureMerkleDrop128" + ); + const drop = await SignatureMerkleDrop128Factory.deploy( + await token.getAddress(), + root, + tree.getDepth() + ); + await token.mint( + await drop.getAddress(), + accountWithDropValues.map((w) => w.amount).reduce((a, b) => a + b, 0) + ); + + const data = MerkleTree.bufferToHex(keccak256(await alice.getAddress())); + const signature = personalSign({ + privateKey: Buffer.from( + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "hex" + ), + data, + }); + + return { + accounts: { owner, alice }, + contracts: { token, drop }, + others: { hashedElements, leaves, proofs, signature }, + }; + } + + it("Should transfer money to another wallet with extra value", async function () { + const { + accounts: { alice }, + contracts: { drop }, + others: { hashedElements, leaves, proofs, signature }, + } = await loadFixture(deployContractsFixture); + const txn = await drop.claim( + alice, + 1, + proofs[leaves.indexOf(hashedElements[0])], + signature, + { value: 10 } + ); + expect(txn).to.changeEtherBalance(ethers, alice, 10); + }); + + it("Should disallow invalid proof", async function () { + const { + accounts: { alice }, + contracts: { drop }, + others: { signature }, + } = await loadFixture(deployContractsFixture); + await expect( + drop.claim(alice, 1, "0x", signature) + ).to.be.revertedWithCustomError(drop, "InvalidProof"); + }); +}); + +/* + +Running Mocha tests + + Hardhat3 test + ✔ Should transfer money to another wallet with extra value (69ms) + ✔ Should disallow invalid proof + + + 2 passing (85ms) + +Unhandled promise rejection: + +HardhatError: HHE100: An internal invariant was violated: The block doesn't exist + at assertHardhatInvariant (/Users/glebalekseev/Documents/git/merkle-distribution/node_modules/@nomicfoundation/hardhat-errors/src/errors.ts:237:11) + at getBalanceChange (/Users/glebalekseev/Documents/git/merkle-distribution/node_modules/@nomicfoundation/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalance.ts:100:3) + at async Promise.all (index 0) +error Command failed with exit code 1. + +*/ diff --git a/v-next/example-project-assertion/tsconfig.json b/v-next/example-project-assertion/tsconfig.json new file mode 100644 index 00000000000..c5b0023b04c --- /dev/null +++ b/v-next/example-project-assertion/tsconfig.json @@ -0,0 +1,50 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "references": [ + { + "path": "../hardhat" + }, + { + "path": "../hardhat-network-helpers" + }, + { + "path": "../hardhat-node-test-runner" + }, + { + "path": "../hardhat-mocha" + }, + { + "path": "../hardhat-keystore" + }, + { + "path": "../hardhat-ethers" + }, + { + "path": "../hardhat-verify" + }, + { + "path": "../hardhat-viem" + }, + { + "path": "../hardhat-viem-assertions" + }, + { + "path": "../hardhat-ethers-chai-matchers" + }, + { + "path": "../hardhat-typechain" + }, + { + "path": "../hardhat-ignition" + }, + { + "path": "../hardhat-ignition-viem" + }, + { + "path": "../hardhat-ledger" + } + ] +} From 03548b02ce3fe885124b778264a0ddd2eccde533 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 23 Feb 2026 23:29:17 +0000 Subject: [PATCH 2/5] [hardhat-mocha] Improve unhandled-rejection-mocha-hook --- v-next/hardhat-mocha/src/unhandled-rejection-mocha-hook.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/v-next/hardhat-mocha/src/unhandled-rejection-mocha-hook.ts b/v-next/hardhat-mocha/src/unhandled-rejection-mocha-hook.ts index ea74fb3dd29..7862185267f 100644 --- a/v-next/hardhat-mocha/src/unhandled-rejection-mocha-hook.ts +++ b/v-next/hardhat-mocha/src/unhandled-rejection-mocha-hook.ts @@ -15,6 +15,7 @@ process.on("unhandledRejection", (e: Error) => { process.on("exit", () => { if (showNotAwaitedError) { + console.log(); console.log( chalk.red( [ From dceaa74e5ffc996bc7afaa316a0074a7914baede Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 23 Feb 2026 23:30:26 +0000 Subject: [PATCH 3/5] Use chai assertions with descriptive error messages instead of Hardhat invariants --- .../src/internal/matchers/addressable.ts | 6 +-- .../internal/matchers/changeEtherBalance.ts | 44 ++++++++----------- .../internal/matchers/changeEtherBalances.ts | 10 ++++- .../internal/matchers/changeTokenBalance.ts | 21 +++++---- .../src/internal/matchers/emit.ts | 9 ++-- .../src/internal/matchers/reverted/revert.ts | 5 ++- .../reverted/revertedWithCustomError.ts | 5 ++- .../src/internal/utils/asserts.ts | 12 +++-- .../src/internal/utils/balance.ts | 19 +++++++- .../test/matchers/changeEtherBalance.ts | 2 +- .../test/matchers/changeTokenBalance.ts | 8 ++-- 11 files changed, 85 insertions(+), 56 deletions(-) diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/addressable.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/addressable.ts index d74d63d2d8e..26f4124dfbc 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/addressable.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/addressable.ts @@ -1,4 +1,4 @@ -import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors"; +import { assert as chaiAssert } from "chai"; import { isAddress, isAddressable } from "ethers"; import { tryDereference } from "../utils/typed.js"; @@ -35,9 +35,9 @@ function tryGetAddressSync(value: any): string | undefined { if ("address" in value) { value = value.address; } else { - assertHardhatInvariant( + chaiAssert.ok( "target" in value, - "target property should exist in value", + "Addressable value should have the property target", ); value = value.target; } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalance.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalance.ts index 95ebc11c684..bb6a9d68339 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalance.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalance.ts @@ -1,19 +1,15 @@ -import type { BalanceChangeOptions } from "../utils/balance.js"; import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types"; import type { Addressable } from "ethers/address"; import type { TransactionResponse } from "ethers/providers"; import type { BigNumberish } from "ethers/utils"; -import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors"; -import { isObject } from "@nomicfoundation/hardhat-utils/lang"; +import { assert as chaiAssert } from "chai"; 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 { assertIsNotNull } from "../utils/asserts.js"; +import { getBalances, type BalanceChangeOptions } from "../utils/balance.js"; import { buildAssert } from "../utils/build-assert.js"; import { preventAsyncMatcherChaining } from "../utils/prevent-chaining.js"; @@ -92,38 +88,34 @@ export async function getBalanceChange( } const txReceipt = await txResponse.wait(); - assertIsNotNull(txReceipt, "txReceipt"); + assertIsNotNull( + txReceipt, + "Transaction's receipt cannot be fetched from the network", + ); const txBlockNumber = txReceipt.blockNumber; const block = await ethers.provider.getBlock(txReceipt.blockHash, false); - assertHardhatInvariant(block !== null, "The block doesn't exist"); + assertIsNotNull( + block, + "The transaction's block cannot be fetched from the network", + ); - assertHardhatInvariant( - isObject(block) && - Array.isArray(block.transactions) && - block.transactions.length === 1, + chaiAssert.equal( + block.transactions.length, + 1, "There should be only 1 transaction in the block", ); const address = await getAddressOf(account); - const balanceAfterHex = await ethers.provider.getBalance( - address, - txBlockNumber, - ); - - const balanceBeforeHex = await ethers.provider.getBalance( - address, + const [balanceAfter] = await getBalances(ethers, [account], txBlockNumber); + const [balanceBefore] = await getBalances( + ethers, + [account], 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; diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts index 71a58467964..7ae20e61b12 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts @@ -145,7 +145,10 @@ export async function getBalanceChanges( const txResponse = await transaction; const txReceipt = await txResponse.wait(); - assertIsNotNull(txReceipt, "txReceipt"); + assertIsNotNull( + txReceipt, + "Transaction's receipt cannot be fetched from the network", + ); const txBlockNumber = txReceipt.blockNumber; const balancesAfter = await getBalances(ethers, accounts, txBlockNumber); @@ -170,7 +173,10 @@ async function getTxFees( (await getAddressOf(account)) === txResponse.from ) { const txReceipt = await txResponse.wait(); - assertIsNotNull(txReceipt, "txReceipt"); + assertIsNotNull( + txReceipt, + "Transaction's receipt cannot be fetched from the network", + ); const gasPrice = txReceipt.gasPrice ?? txResponse.gasPrice; const gasUsed = txReceipt.gasUsed; const txFee = gasPrice * gasUsed; diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts index ddee73bbf8f..5c692bf0ced 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts @@ -8,11 +8,9 @@ import type { } from "ethers"; import type { TransactionResponse } from "ethers/providers"; -import { - assertHardhatInvariant, - HardhatError, -} from "@nomicfoundation/hardhat-errors"; +import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { isObject } from "@nomicfoundation/hardhat-utils/lang"; +import { assert as chaiAssert } from "chai"; import { toBigInt } from "ethers/utils"; import { @@ -237,15 +235,22 @@ export async function getBalanceChange( const txResponse = await transaction; const txReceipt = await txResponse.wait(); - assertIsNotNull(txReceipt, "txReceipt"); + assertIsNotNull( + txReceipt, + "Transaction's receipt cannot be fetched from the network", + ); const txBlockNumber = txReceipt.blockNumber; const block = await ethers.provider.getBlock(txReceipt.blockHash, false); - assertHardhatInvariant(block !== null, "The block doesn't exist"); + assertIsNotNull( + block, + "The transaction's block cannot be fetched from the network", + ); - assertHardhatInvariant( - Array.isArray(block.transactions) && block.transactions.length === 1, + chaiAssert.equal( + block.transactions.length, + 1, "There should be only 1 transaction in the block", ); diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts index dafcbd63232..f551178d1cf 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts @@ -131,7 +131,10 @@ export function supportEmit( return waitForPendingTransaction(tx, contract.runner.provider).then( (receipt) => { - assertIsNotNull(receipt, "receipt"); + assertIsNotNull( + receipt, + "Transaction's receipt cannot be fetched from the network", + ); return onSuccess(receipt); }, ); @@ -183,7 +186,7 @@ const tryAssertArgsArraysEqual = ( const parsedLog = chaiUtils .flag(context, "contract") .interface.parseLog(logs[0]); - assertIsNotNull(parsedLog, "parsedLog"); + assertIsNotNull(parsedLog, "Can't parse the first log"); return assertArgsArraysEqual( Assertion, @@ -204,7 +207,7 @@ const tryAssertArgsArraysEqual = ( const parsedLog = chaiUtils .flag(context, "contract") .interface.parseLog(logs[index]); - assertIsNotNull(parsedLog, "parsedLog"); + assertIsNotNull(parsedLog, `Can't parse the log index ${index}`); assertArgsArraysEqual( Assertion, diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts index cfa1ad2d6ba..5836c3d2ebc 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts @@ -58,7 +58,10 @@ export function supportRevert( } } - assertIsNotNull(receipt, "receipt"); + assertIsNotNull( + receipt, + "Transaction's receipt cannot be fetched from the network", + ); assert( receipt.status === 0, "Expected transaction to be reverted", diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts index dfd1c19cb7c..2ae7411162b 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts @@ -224,7 +224,10 @@ export async function revertedWithCustomErrorWithArgs( const errorFragment = contractInterface.getError(customError.name); - assertIsNotNull(errorFragment, "errorFragment"); + assertIsNotNull( + errorFragment, + "Error type can't be found in the contract's interface", + ); // We transform ether's Array-like object into an actual array as it's safer const actualArgs = resultToArray( diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts index a1ec3c34ef4..52b430a6455 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts @@ -1,10 +1,8 @@ import type { AssertWithSsfi, Ssfi } from "./ssfi.js"; -import { - assertHardhatInvariant, - HardhatError, -} from "@nomicfoundation/hardhat-errors"; +import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; +import { assert as chaiAssert } from "chai"; import { keccak256 } from "ethers/crypto"; import { getBytes, hexlify, isHexString, toUtf8Bytes } from "ethers/utils"; @@ -12,9 +10,9 @@ import { ordinal } from "./ordinal.js"; export function assertIsNotNull( value: T, - valueName: string, + errorMessage: string, ): asserts value is Exclude { - assertHardhatInvariant(value !== null, `${valueName} should not be null`); + chaiAssert.notEqual(value, null, errorMessage); } export function assertArgsArraysEqual( @@ -147,7 +145,7 @@ function innerAssertArgEqual( export function assertCanBeConvertedToBigint( value: unknown, ): asserts value is string | number | bigint { - assertHardhatInvariant( + chaiAssert.ok( typeof value === "string" || typeof value === "number" || typeof value === "bigint", diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/balance.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/balance.ts index 1ed48991225..5b048bd0768 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/balance.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/balance.ts @@ -2,6 +2,8 @@ import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types"; import type { Addressable } from "ethers"; import { toBigInt } from "@nomicfoundation/hardhat-utils/bigint"; +import { ensureError } from "@nomicfoundation/hardhat-utils/error"; +import { assert as chaiAssert } from "chai"; import { getAddressOf } from "./account.js"; import { assertCanBeConvertedToBigint } from "./asserts.js"; @@ -25,7 +27,22 @@ export async function getBalances( accounts.map(async (account) => { const address = await getAddressOf(account); - const result = await ethers.provider.getBalance(address, blockNumber); + let result; + try { + result = await ethers.provider.getBalance(address, blockNumber); + } catch (cause) { + try { + chaiAssert.fail( + "Failed to get the balance of the account " + address, + ); + } catch (e) { + ensureError(e); + e.cause = cause; + + throw e; + } + } + assertCanBeConvertedToBigint(result); return toBigInt(result); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts index 087f4f45709..fb60b2c6f15 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts @@ -101,7 +101,7 @@ describe("INTEGRATION: changeEtherBalance matcher", { timeout: 60000 }, () => { includeFee: true, }), ).to.be.eventually.rejectedWith( - "There should be only 1 transaction in the block", + "There should be only 1 transaction in the block: expected 2 to equal 1", ); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts index 0662ee6502c..64610ec90bb 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts @@ -758,7 +758,7 @@ describe( mockToken.transfer(receiver.address, 50, { gasLimit: 100_000 }), ).to.changeTokenBalance(ethers, mockToken, sender, -50), ).to.be.rejectedWith( - "There should be only 1 transaction in the block", + "There should be only 1 transaction in the block: expected 2 to equal 1", ); }); @@ -867,7 +867,9 @@ describe( await expect( expect( - mockToken.transfer(receiver.address, 50, { gasLimit: 100_000 }), + mockToken.transfer(receiver.address, 50, { + gasLimit: 100_000, + }), ).to.changeTokenBalances( ethers, mockToken, @@ -875,7 +877,7 @@ describe( [-50, 50], ), ).to.be.rejectedWith( - "There should be only 1 transaction in the block", + "There should be only 1 transaction in the block: expected 2 to equal 1", ); }); From 4c66a25ed488be93808c78916ce0c9e0f6e2ed64 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Mon, 23 Feb 2026 23:58:25 +0000 Subject: [PATCH 4/5] Preseve all caught errors as cause --- .../src/internal/matchers/emit.ts | 5 +++-- .../matchers/reverted/revertedWithPanic.ts | 6 +++++- .../src/internal/matchers/reverted/utils.ts | 2 ++ .../src/internal/utils/asserts.ts | 18 ++++++++++++------ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts index f551178d1cf..5bdf7f7c9d4 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts @@ -74,8 +74,9 @@ export function supportEmit( } 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); + const error = new AssertionError(errorMessage); + error.cause = e; + throw error; } } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts index 5aa481fafa7..b837e413ab4 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts @@ -1,5 +1,6 @@ import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { toBigInt } from "@nomicfoundation/hardhat-utils/bigint"; +import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex"; import { REVERTED_WITH_PANIC_MATCHER } from "../../constants.js"; @@ -24,7 +25,9 @@ export function supportRevertedWithPanic( if (expectedCodeArg !== undefined) { expectedCode = toBigInt(expectedCodeArg); } - } catch { + } catch (e) { + ensureError(e); + // if the input validation fails, we discard the subject since it could // potentially be a rejected promise Promise.resolve(this._obj).catch(() => {}); @@ -34,6 +37,7 @@ export function supportRevertedWithPanic( { panicCode: expectedCodeArg, }, + e, ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts index c8455137f5f..03174bd582a 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts @@ -85,6 +85,7 @@ export function decodeReturnData(returnData: string): DecodedReturnData { type: "string", reason: e.message, }, + e, ); } @@ -107,6 +108,7 @@ export function decodeReturnData(returnData: string): DecodedReturnData { type: "uint256", reason: e.message, }, + e, ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts index 52b430a6455..8fdae43f48d 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts @@ -86,12 +86,18 @@ function innerAssertArgEqual( } 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 - ); + try { + assert( + false, + `The predicate threw when called: ${e.message}`, + // no need for a negated message, since we disallow mixing .not. with + // .withArgs + ); + } catch (assertError) { + ensureError(assertError); + assertError.cause = e; + throw assertError; + } } assert( false, From 6009caf618d60e4f45934f7de9b234638a33a167 Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Tue, 24 Feb 2026 00:27:38 +0000 Subject: [PATCH 5/5] Don't use HardhatError within assertions --- .../hardhat-ethers-chai-matchers/package.json | 1 - .../src/internal/matchers/big-number.ts | 11 +- .../internal/matchers/changeEtherBalances.ts | 10 +- .../internal/matchers/changeTokenBalance.ts | 20 +-- .../src/internal/matchers/emit.ts | 20 +-- .../matchers/reverted/legacyReverted.ts | 6 +- .../src/internal/matchers/reverted/revert.ts | 9 +- .../matchers/reverted/revertedWith.ts | 6 +- .../reverted/revertedWithCustomError.ts | 25 ++-- .../matchers/reverted/revertedWithPanic.ts | 22 ++-- .../src/internal/matchers/reverted/utils.ts | 51 ++++---- .../src/internal/matchers/withArgs.ts | 15 +-- .../src/internal/utils/account.ts | 9 +- .../src/internal/utils/asserts.ts | 5 +- .../src/internal/utils/build-assert.ts | 11 +- .../src/internal/utils/prevent-chaining.ts | 10 +- .../test/matchers/changeEtherBalance.ts | 16 ++- .../test/matchers/changeEtherBalances.ts | 16 ++- .../test/matchers/changeTokenBalance.ts | 65 +++++----- .../test/matchers/events.ts | 12 +- .../test/matchers/reverted/legacyReverted.ts | 32 +++-- .../test/matchers/reverted/revert.ts | 118 +++++++++--------- .../test/matchers/reverted/revertedWith.ts | 23 ++-- .../reverted/revertedWithCustomError.ts | 43 ++++--- .../matchers/reverted/revertedWithPanic.ts | 25 ++-- 25 files changed, 271 insertions(+), 310 deletions(-) diff --git a/v-next/hardhat-ethers-chai-matchers/package.json b/v-next/hardhat-ethers-chai-matchers/package.json index 43f34dd5faa..9b4c2b09114 100644 --- a/v-next/hardhat-ethers-chai-matchers/package.json +++ b/v-next/hardhat-ethers-chai-matchers/package.json @@ -63,7 +63,6 @@ "typescript": "~5.8.0" }, "dependencies": { - "@nomicfoundation/hardhat-errors": "workspace:^3.0.5", "@nomicfoundation/hardhat-utils": "workspace:^3.0.5", "@types/chai-as-promised": "^8.0.1", "chai-as-promised": "^8.0.0", diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/big-number.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/big-number.ts index 000f99f5613..4715f9a865b 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/big-number.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/big-number.ts @@ -1,8 +1,7 @@ import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { toBigInt } from "@nomicfoundation/hardhat-utils/bigint"; -import { AssertionError } from "chai"; +import { assert as chaiAssert, AssertionError } from "chai"; import deepEqual from "deep-eql"; import { isBigInt } from "../utils/bigint.js"; @@ -120,11 +119,9 @@ function overwriteBigNumberFunction( } else if (method === "lte") { return lhs <= rhs; } else { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.UNKNOWN_COMPARISON_OPERATION, - { - method, - }, + chaiAssert.fail( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Unreachable + `Unknown comparison operation "${method as any}"`, ); } } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts index 7ae20e61b12..21b94b211e4 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalances.ts @@ -3,7 +3,7 @@ import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types"; import type { Addressable } from "ethers/address"; import type { TransactionResponse } from "ethers/providers"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assert as chaiAssert } from "chai"; import { toBigInt } from "ethers/utils"; import { CHANGE_ETHER_BALANCES_MATCHER } from "../constants.js"; @@ -120,12 +120,8 @@ function validateInput( Array.isArray(balanceChanges) && accounts.length !== balanceChanges.length ) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.ACCOUNTS_NUMBER_DIFFERENT_FROM_BALANCE_CHANGES, - { - accounts: accounts.length, - balanceChanges: balanceChanges.length, - }, + chaiAssert.fail( + `The number of accounts (${accounts.length}) is different than the number of expected balance changes (${balanceChanges.length})`, ); } } catch (e) { diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts index 5c692bf0ced..191a3ef506e 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/changeTokenBalance.ts @@ -8,7 +8,6 @@ import type { } from "ethers"; import type { TransactionResponse } from "ethers/providers"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { isObject } from "@nomicfoundation/hardhat-utils/lang"; import { assert as chaiAssert } from "chai"; import { toBigInt } from "ethers/utils"; @@ -188,12 +187,8 @@ function validateInput( Array.isArray(balanceChanges) && accounts.length !== balanceChanges.length ) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.ACCOUNTS_NUMBER_DIFFERENT_FROM_BALANCE_CHANGES, - { - accounts: accounts.length, - balanceChanges: balanceChanges.length, - }, + chaiAssert.fail( + `The number of accounts (${accounts.length}) is different than the number of expected balance changes (${balanceChanges.length})`, ); } } catch (e) { @@ -206,11 +201,8 @@ function validateInput( function checkToken(token: unknown, method: string) { if (!isObject(token) || token === null || !("interface" in token)) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, - { - method, - }, + chaiAssert.fail( + `The first argument of "${method}" must be the contract instance of the token`, ); } else if ( isObject(token) && @@ -220,9 +212,7 @@ function checkToken(token: unknown, method: string) { typeof token.interface.getFunction === "function" && token.interface.getFunction("balanceOf") === null ) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.CONTRACT_IS_NOT_AN_ERC20_TOKEN, - ); + chaiAssert.fail("The given contract instance is not an ERC20 token"); } } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts index 5bdf7f7c9d4..76cf4da944e 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/emit.ts @@ -6,8 +6,7 @@ import type { Transaction } from "ethers/transaction"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; -import { AssertionError } from "chai"; +import { assert as chaiAssert, AssertionError } from "chai"; import { ASSERTION_ABORTED, EMIT_MATCHER } from "../constants.js"; import { assertArgsArraysEqual, assertIsNotNull } from "../utils/asserts.js"; @@ -30,10 +29,7 @@ async function waitForPendingTransaction( } if (hash === null) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.INVALID_TRANSACTION, - { transaction: JSON.stringify(tx) }, - ); + chaiAssert.fail(`"${JSON.stringify(tx)}" is not a valid transaction`); } return provider.getTransactionReceipt(hash); @@ -90,14 +86,12 @@ export function supportEmit( const topic = eventFragment.topicHash; const contractAddress = contract.target; if (typeof contractAddress !== "string") { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.CONTRACT_TARGET_MUST_BE_A_STRING, - ); + chaiAssert.fail("The contract target should be a string"); } if (args.length > 0) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.EMIT_EXPECTS_TWO_ARGUMENTS, + chaiAssert.fail( + "The .emit matcher expects two arguments: the contract and the event name. Arguments should be asserted with the .withArgs helper.", ); } @@ -125,9 +119,7 @@ export function supportEmit( } if (contract.runner === null || contract.runner.provider === null) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.CONTRACT_RUNNER_PROVIDER_NOT_NULL, - ); + chaiAssert.fail("contract.runner.provider shouldn't be null"); } return waitForPendingTransaction(tx, contract.runner.provider).then( diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/legacyReverted.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/legacyReverted.ts index 519a6713f5f..bf1c1a317ca 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/legacyReverted.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/legacyReverted.ts @@ -1,4 +1,4 @@ -import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assert as chaiAssert } from "chai"; import { LEGACY_REVERTED_MATCHER } from "../../constants.js"; @@ -12,8 +12,8 @@ export function supportLegacyReverted( this._obj.catch(() => {}); } - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.DEPRECATED_REVERTED_MATCHER, + chaiAssert.fail( + "The .reverted matcher has been deprecated. Use .revert(ethers) instead.", ); }); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts index 5836c3d2ebc..e8c3bb90f92 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revert.ts @@ -1,7 +1,7 @@ import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex"; +import { assert as chaiAssert } from "chai"; import { REVERT_MATCHER } from "../../constants.js"; import { assertIsNotNull } from "../../utils/asserts.js"; @@ -40,11 +40,8 @@ export function supportRevert( const hash = typeof value === "string" ? value : value.hash; if (!isValidTransactionHash(hash)) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.EXPECTED_VALID_TRANSACTION_HASH, - { - hash, - }, + chaiAssert.fail( + `Expected a valid transaction hash, but got "${hash}"`, ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWith.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWith.ts index e1e5522d19a..cb2653048df 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWith.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWith.ts @@ -1,5 +1,5 @@ -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex"; +import { assert as chaiAssert } from "chai"; import { REVERTED_WITH_MATCHER } from "../../constants.js"; import { buildAssert } from "../../utils/build-assert.js"; @@ -26,8 +26,8 @@ export function supportRevertedWith( // potentially be a rejected promise Promise.resolve(this._obj).catch(() => {}); - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, + chaiAssert.fail( + "Expected the revert reason to be a string or a regular expression", ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts index 2ae7411162b..d3ac2484cf9 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithCustomError.ts @@ -2,8 +2,8 @@ import type { Ssfi } from "../../utils/ssfi.js"; import type { ErrorFragment, Interface } from "ethers/abi"; import type { BaseContract } from "ethers/contract"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex"; +import { assert as chaiAssert } from "chai"; import { ASSERTION_ABORTED, @@ -160,16 +160,14 @@ function validateInput( // 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.GENERAL.FIRST_ARGUMENT_MUST_BE_A_CONTRACT, + chaiAssert.fail( + "The first argument of .revertedWithCustomError must be the contract that defines the custom error", ); } // validate custom error name if (typeof expectedCustomErrorName !== "string") { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.STRING_EXPECTED_AS_CUSTOM_ERROR_NAME, - ); + chaiAssert.fail("Expected the custom error name to be a string"); } const iface = contract.interface; @@ -177,17 +175,14 @@ function validateInput( // check that interface contains the given custom error if (expectedCustomError === null) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.CONTRACT_DOES_NOT_HAVE_CUSTOM_ERROR, - { - customErrorName: expectedCustomErrorName, - }, + chaiAssert.fail( + `The given contract doesn't have a custom error named "${expectedCustomErrorName}"`, ); } if (args.length > 0) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.REVERT_INVALID_ARGUMENTS_LENGTH, + chaiAssert.fail( + "The .revertedWithCustomError matcher expects two arguments: the contract and the custom error name. Arguments should be asserted with the .withArgs helper.", ); } @@ -214,8 +209,8 @@ export async function revertedWithCustomErrorWithArgs( context.customErrorData; if (customErrorAssertionData === undefined) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.WITH_ARGS_FORBIDDEN, + chaiAssert.fail( + "[.withArgs] should never happen, please submit an issue to the Hardhat repository", ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts index b837e413ab4..57bc182280d 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/revertedWithPanic.ts @@ -1,7 +1,7 @@ -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { toBigInt } from "@nomicfoundation/hardhat-utils/bigint"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex"; +import { assert as chaiAssert } from "chai"; import { REVERTED_WITH_PANIC_MATCHER } from "../../constants.js"; import { buildAssert } from "../../utils/build-assert.js"; @@ -25,20 +25,22 @@ export function supportRevertedWithPanic( if (expectedCodeArg !== undefined) { expectedCode = toBigInt(expectedCodeArg); } - } catch (e) { - ensureError(e); + } catch (cause) { + ensureError(cause); // 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.GENERAL.PANIC_CODE_EXPECTED, - { - panicCode: expectedCodeArg, - }, - e, - ); + try { + chaiAssert.fail( + `Expected the given panic code to be a number-like value, but got "${expectedCodeArg}"`, + ); + } catch (e) { + ensureError(e); + e.cause = cause; + throw e; + } } const code: bigint | undefined = expectedCode; diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts index 03174bd582a..c716d6b99e7 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/reverted/utils.ts @@ -1,8 +1,7 @@ import type { Result } from "ethers/abi"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; -import { AssertionError } from "chai"; +import { assert as chaiAssert, AssertionError } from "chai"; import { AbiCoder, decodeBytes32String } from "ethers/abi"; import { panicErrorCodeToReason } from "./panic.js"; @@ -75,18 +74,18 @@ export function decodeReturnData(returnData: string): DecodedReturnData { try { reason = abi.decode(["string"], `0x${encodedReason}`)[0]; - } catch (e) { - ensureError(e); - - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.DECODING_ERROR, - { - encodedData: encodedReason, - type: "string", - reason: e.message, - }, - e, - ); + } catch (cause) { + ensureError(cause); + + try { + chaiAssert.fail( + `There was an error decoding "${encodedReason}" as a "string. Reason: ${cause.message}"`, + ); + } catch (e) { + ensureError(e); + e.cause = cause; + throw e; + } } return { @@ -98,18 +97,18 @@ export function decodeReturnData(returnData: string): DecodedReturnData { let code: bigint; try { code = abi.decode(["uint256"], `0x${encodedReason}`)[0]; - } catch (e) { - ensureError(e); - - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.DECODING_ERROR, - { - encodedData: encodedReason, - type: "uint256", - reason: e.message, - }, - e, - ); + } catch (cause) { + ensureError(cause); + + try { + chaiAssert.fail( + `There was an error decoding "${encodedReason}" as a "uint256. Reason: ${cause.message}"`, + ); + } catch (e) { + ensureError(e); + e.cause = cause; + throw e; + } } const description = panicErrorCodeToReason(code) ?? "unknown panic code"; diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/withArgs.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/withArgs.ts index 0de464f3800..af54a3315ca 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/withArgs.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/matchers/withArgs.ts @@ -1,6 +1,5 @@ -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { toBigInt } from "@nomicfoundation/hardhat-utils/bigint"; -import { AssertionError } from "chai"; +import { assert as chaiAssert, AssertionError } from "chai"; import { isAddressable } from "ethers/address"; import { ASSERTION_ABORTED } from "../constants.js"; @@ -104,9 +103,7 @@ function validateInput( ): { emitCalled: boolean } { try { if (Boolean(this.__flags.negate)) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.WITH_ARGS_CANNOT_BE_COMBINED_WITH_NOT, - ); + chaiAssert.fail("Do not combine .not. with .withArgs()"); } const emitCalled = chaiUtils.flag(this, EMIT_CALLED) === true; @@ -115,14 +112,14 @@ function validateInput( chaiUtils.flag(this, REVERTED_WITH_CUSTOM_ERROR_CALLED) === true; if (!emitCalled && !revertedWithCustomErrorCalled) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.WITH_ARGS_WRONG_COMBINATION, + chaiAssert.fail( + "withArgs can only be used in combination with a previous .emit or .revertedWithCustomError assertion", ); } if (emitCalled && revertedWithCustomErrorCalled) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.WITH_ARGS_COMBINED_WITH_INCOMPATIBLE_ASSERTIONS, + chaiAssert.fail( + "withArgs called with both .emit and .revertedWithCustomError, but these assertions cannot be combined", ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/account.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/account.ts index 87dff8864e0..76ef2cfb597 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/account.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/account.ts @@ -1,7 +1,7 @@ import type { Addressable } from "ethers/address"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { isAddress } from "@nomicfoundation/hardhat-utils/eth"; +import { assert as chaiAssert } from "chai"; import { isAddressable } from "ethers/address"; export async function getAddressOf( @@ -15,10 +15,5 @@ export async function getAddressOf( return account.getAddress(); } - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.EXPECTED_STRING_OR_ADDRESSABLE, - { - account, - }, - ); + chaiAssert.fail(`Expected string or addressable, but got "${account}"`); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts index 8fdae43f48d..28540d477f7 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/asserts.ts @@ -1,6 +1,5 @@ import type { AssertWithSsfi, Ssfi } from "./ssfi.js"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { assert as chaiAssert } from "chai"; import { keccak256 } from "ethers/crypto"; @@ -122,8 +121,8 @@ function innerAssertArgEqual( } else { if (actualArg.hash !== undefined && actualArg._isIndexed === true) { if (assertionType !== "event") { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.INDEXED_EVENT_FORBIDDEN, + chaiAssert.fail( + "Should not get an indexed event when the assertion type is not event. Please open an issue about this.", ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/build-assert.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/build-assert.ts index 1fb648ded8a..14e6c26e738 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/build-assert.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/build-assert.ts @@ -1,7 +1,6 @@ import type { Ssfi } from "./ssfi.js"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; -import { AssertionError } from "chai"; +import { assert as chaiAssert, AssertionError } from "chai"; /** * This function is used by the matchers to obtain an `assert` function, which @@ -27,8 +26,8 @@ export function buildAssert(negated: boolean, ssfi: Ssfi) { ): void { if (!negated && !condition) { if (messageFalse === undefined) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.ASSERTION_WITHOUT_ERROR_MESSAGE, + chaiAssert.fail( + "Assertion doesn't have an error message. Please open an issue to report this.", ); } @@ -40,8 +39,8 @@ export function buildAssert(negated: boolean, ssfi: Ssfi) { if (negated && condition) { if (messageTrue === undefined) { - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.ASSERTION_WITHOUT_ERROR_MESSAGE, + chaiAssert.fail( + "Assertion doesn't have an error message. Please open an issue to report this.", ); } diff --git a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/prevent-chaining.ts b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/prevent-chaining.ts index 64888c63243..11a290711e4 100644 --- a/v-next/hardhat-ethers-chai-matchers/src/internal/utils/prevent-chaining.ts +++ b/v-next/hardhat-ethers-chai-matchers/src/internal/utils/prevent-chaining.ts @@ -1,4 +1,4 @@ -import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assert as chaiAssert } from "chai"; import { PREVIOUS_MATCHER_NAME } from "../constants.js"; @@ -23,11 +23,7 @@ export function preventAsyncMatcherChaining( return; } - throw new HardhatError( - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: matcherName, - previousMatcher: previousMatcherName, - }, + chaiAssert.fail( + `The matcher "${matcherName}" cannot be chained after "${previousMatcherName}". For more information, please refer to the documentation at: (https://hardhat.org/chaining-async-matchers).`, ); } diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts index fb60b2c6f15..92a510d1295 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalance.ts @@ -10,9 +10,8 @@ import path from "node:path"; import { before, beforeEach, describe, it } from "node:test"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertThrowsHardhatError, + assertThrows, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { expect, AssertionError } from "chai"; @@ -621,7 +620,7 @@ describe("INTEGRATION: changeEtherBalance matcher", { timeout: 60000 }, () => { }); it("should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect( sender.sendTransaction({ @@ -631,12 +630,11 @@ describe("INTEGRATION: changeEtherBalance matcher", { timeout: 60000 }, () => { ) .to.changeTokenBalance(ethers, mockToken, receiver, 0) .and.to.changeEtherBalance(ethers, sender, "-200"), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "changeEtherBalance", - previousMatcher: "changeTokenBalance", - }, + (e) => + e.message.includes( + 'The matcher "changeEtherBalance" cannot be chained after "changeTokenBalance"', + ), + "Expected chaining error message", ); }); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalances.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalances.ts index 9194c76a4f2..751fa261a78 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalances.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeEtherBalances.ts @@ -10,9 +10,8 @@ import path from "node:path"; import { before, beforeEach, describe, it } from "node:test"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertThrowsHardhatError, + assertThrows, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { expect, AssertionError } from "chai"; @@ -349,7 +348,7 @@ describe("INTEGRATION: changeEtherBalances matcher", { timeout: 60000 }, () => { }); it("should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect( sender.sendTransaction({ @@ -368,12 +367,11 @@ describe("INTEGRATION: changeEtherBalances matcher", { timeout: 60000 }, () => { [sender, contract], [-200, 200], ), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "changeEtherBalances", - previousMatcher: "changeTokenBalances", - }, + (e) => + e.message.includes( + 'The matcher "changeEtherBalances" cannot be chained after "changeTokenBalances"', + ), + "Expected chaining error message", ); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts index 64610ec90bb..45b52824a5f 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/changeTokenBalance.ts @@ -16,9 +16,8 @@ import path from "node:path"; import { afterEach, before, beforeEach, describe, it } from "node:test"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertThrowsHardhatError, + assertThrows, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { AssertionError, expect } from "chai"; @@ -655,22 +654,21 @@ describe( }); it("changeTokenBalance: Should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect(contract.emitWithoutArgs()) .to.emit(contract, "WithoutArgs") .and.to.changeTokenBalance(ethers, mockToken, receiver, 0), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "changeTokenBalance", - previousMatcher: "emit", - }, + (e) => + e.message.includes( + 'The matcher "changeTokenBalance" cannot be chained after "emit"', + ), + "Expected chaining error message", ); }); it("changeTokenBalances: should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect(matchers.revertWithCustomErrorWithInt(1)) .to.be.revert(ethers) @@ -680,12 +678,11 @@ describe( [sender, receiver], [-50, 100], ), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "changeTokenBalances", - previousMatcher: "revert", - }, + (e) => + e.message.includes( + 'The matcher "changeTokenBalances" cannot be chained after "revert"', + ), + "Expected chaining error message", ); }); }); @@ -694,31 +691,31 @@ describe( describe("validation errors", () => { describe(CHANGE_TOKEN_BALANCE_MATCHER, () => { it("token is not specified", async () => { - assertThrowsHardhatError( + assertThrows( () => expect( mockToken.transfer(receiver.address, 50), // @ts-expect-error -- force error scenario: token should be specified ).to.changeTokenBalance(ethers, receiver, 50), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, - { - method: CHANGE_TOKEN_BALANCE_MATCHER, - }, + (e) => + e.message.includes( + `The first argument of "${CHANGE_TOKEN_BALANCE_MATCHER}" must be the contract instance of the token`, + ), + "Expected contract instance error message", ); // if an address is used (receiver.address) - assertThrowsHardhatError( + assertThrows( () => expect( mockToken.transfer(receiver.address, 50), // @ts-expect-error -- force error scenario: token should be specified ).to.changeTokenBalance(ethers, receiver.address, 50), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, - { - method: CHANGE_TOKEN_BALANCE_MATCHER, - }, + (e) => + e.message.includes( + `The first argument of "${CHANGE_TOKEN_BALANCE_MATCHER}" must be the contract instance of the token`, + ), + "Expected contract instance error message", ); }); @@ -777,17 +774,17 @@ describe( describe(CHANGE_TOKEN_BALANCES_MATCHER, () => { it("token is not specified", async () => { - assertThrowsHardhatError( + assertThrows( () => expect( mockToken.transfer(receiver.address, 50), // @ts-expect-error -- force error scenario: token should be specified ).to.changeTokenBalances(ethers, [sender, receiver], [-50, 50]), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .FIRST_ARGUMENT_MUST_BE_A_CONTRACT_INSTANCE, - { - method: CHANGE_TOKEN_BALANCES_MATCHER, - }, + (e) => + e.message.includes( + `The first argument of "${CHANGE_TOKEN_BALANCES_MATCHER}" must be the contract instance of the token`, + ), + "Expected contract instance error message", ); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/events.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/events.ts index 6d231f72299..2c9955c682e 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/events.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/events.ts @@ -8,9 +8,8 @@ import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types"; import { before, beforeEach, describe, it } from "node:test"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertRejectsWithHardhatError, + assertRejects, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { expect, AssertionError } from "chai"; @@ -80,12 +79,15 @@ describe(".to.emit (contract events)", { timeout: 60000 }, () => { }); it("should fail when matcher is called with too many arguments", async () => { - await assertRejectsWithHardhatError( + await assertRejects( () => // @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.GENERAL.EMIT_EXPECTS_TWO_ARGUMENTS, - {}, + (e) => + e.message.includes( + "The .emit matcher expects two arguments: the contract and the event name. Arguments should be asserted with the .withArgs helper.", + ), + "Expected emit arguments error message", ); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/legacyReverted.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/legacyReverted.ts index e5130ad48c9..9cbd03c9447 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/legacyReverted.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/legacyReverted.ts @@ -1,9 +1,8 @@ import { describe, it } from "node:test"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertRejectsWithHardhatError, - assertThrowsHardhatError, + assertRejects, + assertThrows, } from "@nomicfoundation/hardhat-test-utils"; import { expect } from "chai"; @@ -14,34 +13,43 @@ addChaiMatchers(); describe("INTEGRATION: Reverted", { timeout: 60000 }, () => { describe("Throwing deprecation error", () => { it("Should throw the right error", async () => { - await assertRejectsWithHardhatError( + await assertRejects( async () => { await expect(() => {}).to.reverted; }, - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.DEPRECATED_REVERTED_MATCHER, - {}, + (e) => + e.message.includes( + "The .reverted matcher has been deprecated. Use .revert(ethers) instead.", + ), + "Expected deprecated reverted matcher error message", ); }); it("Should also throw in a sync context", async () => { - assertThrowsHardhatError( + assertThrows( () => { void expect(() => {}).to.reverted; }, - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.DEPRECATED_REVERTED_MATCHER, - {}, + (e) => + e.message.includes( + "The .reverted matcher has been deprecated. Use .revert(ethers) instead.", + ), + "Expected deprecated reverted matcher error message", ); }); it("Should work with a promise that rejects", async () => { - await assertRejectsWithHardhatError( + await assertRejects( async () => { await expect(async () => { throw new Error("foo"); }).to.reverted; }, - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.DEPRECATED_REVERTED_MATCHER, - {}, + (e) => + e.message.includes( + "The .reverted matcher has been deprecated. Use .revert(ethers) instead.", + ), + "Expected deprecated reverted matcher error message", ); }); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revert.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revert.ts index c2e074bad89..abb8a331cec 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revert.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revert.ts @@ -6,10 +6,9 @@ import path from "node:path"; import { before, beforeEach, describe, it } from "node:test"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertRejectsWithHardhatError, - assertThrowsHardhatError, + assertRejects, + assertThrows, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { AssertionError, expect } from "chai"; @@ -80,22 +79,22 @@ describe("INTEGRATION: Revert", { timeout: 60000 }, () => { }); it("invalid string", async () => { - await assertRejectsWithHardhatError( + await assertRejects( () => expect("0x123").to.be.revert(ethers), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .EXPECTED_VALID_TRANSACTION_HASH, - { - hash: "0x123", - }, + (e) => + e.message.includes( + 'Expected a valid transaction hash, but got "0x123"', + ), + "Expected invalid transaction hash error message", ); - await assertRejectsWithHardhatError( + await assertRejects( () => expect("0x123").to.not.be.revert(ethers), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .EXPECTED_VALID_TRANSACTION_HASH, - { - hash: "0x123", - }, + (e) => + e.message.includes( + 'Expected a valid transaction hash, but got "0x123"', + ), + "Expected invalid transaction hash error message", ); }); @@ -122,22 +121,22 @@ describe("INTEGRATION: Revert", { timeout: 60000 }, () => { }); it("promise of an invalid string", async () => { - await assertRejectsWithHardhatError( + await assertRejects( () => expect(Promise.resolve("0x123")).to.be.revert(ethers), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .EXPECTED_VALID_TRANSACTION_HASH, - { - hash: "0x123", - }, + (e) => + e.message.includes( + 'Expected a valid transaction hash, but got "0x123"', + ), + "Expected invalid transaction hash error message", ); - await assertRejectsWithHardhatError( + await assertRejects( () => expect(Promise.resolve("0x123")).to.not.be.revert(ethers), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .EXPECTED_VALID_TRANSACTION_HASH, - { - hash: "0x123", - }, + (e) => + e.message.includes( + 'Expected a valid transaction hash, but got "0x123"', + ), + "Expected invalid transaction hash error message", ); }); @@ -192,69 +191,65 @@ describe("INTEGRATION: Revert", { timeout: 60000 }, () => { }); it("reverted: should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect(matchers.revertsWith("bar")) .to.be.revertedWith("bar") .and.to.be.revert(ethers), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "revert", - previousMatcher: "revertedWith", - }, + (e) => + e.message.includes( + 'The matcher "revert" cannot be chained after "revertedWith"', + ), + "Expected chaining error message", ); }); it("revertedWith: should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect(matchers.revertWithCustomErrorWithInt(1)) .to.be.revertedWithCustomError(matchers, "CustomErrorWithInt") .and.to.be.revertedWith("an error message"), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "revertedWith", - previousMatcher: "revertedWithCustomError", - }, + (e) => + e.message.includes( + 'The matcher "revertedWith" cannot be chained after "revertedWithCustomError"', + ), + "Expected chaining error message", ); }); it("revertedWithCustomError: should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect(matchers.revertsWithoutReason()) .to.be.revertedWithoutReason(ethers) .and.to.be.revertedWithCustomError(matchers, "SomeCustomError"), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "revertedWithCustomError", - previousMatcher: "revertedWithoutReason", - }, + (e) => + e.message.includes( + 'The matcher "revertedWithCustomError" cannot be chained after "revertedWithoutReason"', + ), + "Expected chaining error message", ); }); it("revertedWithoutReason: should throw if chained to another non-chainable method", () => { - assertThrowsHardhatError( + assertThrows( () => expect(matchers.panicAssert()) .to.be.revertedWithPanic() .and.to.be.revertedWithoutReason(ethers), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "revertedWithoutReason", - previousMatcher: "revertedWithPanic", - }, + (e) => + e.message.includes( + 'The matcher "revertedWithoutReason" cannot be chained after "revertedWithPanic"', + ), + "Expected chaining error message", ); }); it("revertedWithPanic: should throw if chained to another non-chainable method", async () => { const [sender, receiver] = await ethers.getSigners(); - assertThrowsHardhatError( + assertThrows( () => expect(() => sender.sendTransaction({ @@ -264,12 +259,11 @@ describe("INTEGRATION: Revert", { timeout: 60000 }, () => { ) .to.changeEtherBalance(ethers, sender, "-200") .and.to.be.revertedWithPanic(), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .MATCHER_CANNOT_BE_CHAINED_AFTER, - { - matcher: "revertedWithPanic", - previousMatcher: "changeEtherBalance", - }, + (e) => + e.message.includes( + 'The matcher "revertedWithPanic" cannot be chained after "changeEtherBalance"', + ), + "Expected chaining error message", ); }); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWith.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWith.ts index a8503049386..4afac9f16a6 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWith.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWith.ts @@ -6,9 +6,8 @@ import path from "node:path"; import { before, beforeEach, describe, it } from "node:test"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertThrowsHardhatError, + assertThrows, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { AssertionError, expect } from "chai"; @@ -200,24 +199,28 @@ describe("INTEGRATION: Reverted with", { timeout: 60000 }, () => { it("non-string as expectation", async () => { const { hash } = await mineSuccessfulTransaction(provider, ethers); - assertThrowsHardhatError( + assertThrows( // @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.GENERAL - .EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, - {}, + (e) => + e.message.includes( + "Expected the revert reason to be a string or a regular expression", + ), + "Expected revert reason type error message", ); }); it("non-string as expectation, subject is a rejected promise", async () => { const tx = matchers.revertsWithoutReason(); - assertThrowsHardhatError( + assertThrows( // @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.GENERAL - .EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, - {}, + (e) => + e.message.includes( + "Expected the revert reason to be a string or a regular expression", + ), + "Expected revert reason type error message", ); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithCustomError.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithCustomError.ts index 347b923a116..8126b94d8b0 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithCustomError.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithCustomError.ts @@ -6,9 +6,8 @@ import path from "node:path"; import { before, beforeEach, describe, it } from "node:test"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertThrowsHardhatError, + assertThrows, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { AssertionError, expect } from "chai"; @@ -459,37 +458,43 @@ describe("INTEGRATION: Reverted with custom error", { timeout: 60000 }, () => { it("non-string as expectation", async () => { const { hash } = await mineSuccessfulTransaction(provider, ethers); - assertThrowsHardhatError( + assertThrows( // @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.GENERAL - .EXPECT_STRING_OR_REGEX_AS_REVERT_REASON, - {}, + (e) => + e.message.includes( + "Expected the revert reason to be a string or a regular expression", + ), + "Expected revert reason type error message", ); }); it("the contract is not specified", async () => { - assertThrowsHardhatError( + assertThrows( () => expect( matchers.revertWithSomeCustomError(), // @ts-expect-error -- force error scenario: contract should be specified ).to.be.revertedWithCustomError("SomeCustomError"), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .FIRST_ARGUMENT_MUST_BE_A_CONTRACT, - {}, + (e) => + e.message.includes( + "The first argument of .revertedWithCustomError must be the contract that defines the custom error", + ), + "Expected contract argument error message", ); }); it("the contract doesn't have a custom error with that name", async () => { - assertThrowsHardhatError( + assertThrows( () => expect( matchers.revertWithSomeCustomError(), ).to.be.revertedWithCustomError(matchers, "SomeCustmError"), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .CONTRACT_DOES_NOT_HAVE_CUSTOM_ERROR, - { customErrorName: "SomeCustmError" }, + (e) => + e.message.includes( + `The given contract doesn't have a custom error named "SomeCustmError"`, + ), + "Expected custom error not found error message", ); }); @@ -518,7 +523,7 @@ describe("INTEGRATION: Reverted with custom error", { timeout: 60000 }, () => { }); it("extra arguments", async () => { - assertThrowsHardhatError( + assertThrows( () => expect( matchers.revertWithSomeCustomError(), @@ -528,9 +533,11 @@ describe("INTEGRATION: Reverted with custom error", { timeout: 60000 }, () => { // @ts-expect-error -- force error scenario: extra arguments should not be specified "extraArgument", ), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL - .REVERT_INVALID_ARGUMENTS_LENGTH, - {}, + (e) => + e.message.includes( + "The .revertedWithCustomError matcher expects two arguments: the contract and the custom error name. Arguments should be asserted with the .withArgs helper.", + ), + "Expected invalid arguments length error message", ); }); }); diff --git a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithPanic.ts b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithPanic.ts index 47b01228562..f2e54f49318 100644 --- a/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithPanic.ts +++ b/v-next/hardhat-ethers-chai-matchers/test/matchers/reverted/revertedWithPanic.ts @@ -6,9 +6,8 @@ import path from "node:path"; import { before, beforeEach, describe, it } from "node:test"; import util from "node:util"; -import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { - assertThrowsHardhatError, + assertThrows, useEphemeralFixtureProject, } from "@nomicfoundation/hardhat-test-utils"; import { AssertionError, expect } from "chai"; @@ -272,24 +271,26 @@ describe("INTEGRATION: Reverted with panic", { timeout: 60000 }, () => { it("non-number as expectation", async () => { const { hash } = await mineSuccessfulTransaction(provider, ethers); - assertThrowsHardhatError( + assertThrows( () => expect(hash).to.be.revertedWithPanic("invalid"), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.PANIC_CODE_EXPECTED, - { - panicCode: "invalid", - }, + (e) => + e.message.includes( + 'Expected the given panic code to be a number-like value, but got "invalid"', + ), + "Expected panic code type error message", ); }); it("non-number as expectation, subject is a rejected promise", async () => { const tx = matchers.revertsWithoutReason(); - assertThrowsHardhatError( + assertThrows( () => expect(tx).to.be.revertedWithPanic("invalid"), - HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.PANIC_CODE_EXPECTED, - { - panicCode: "invalid", - }, + (e) => + e.message.includes( + 'Expected the given panic code to be a number-like value, but got "invalid"', + ), + "Expected panic code type error message", ); });