Skip to content

Commit

Permalink
Merge pull request #2652 from NomicFoundation/francovictorio/hh-581/r…
Browse files Browse the repository at this point in the history
…everted-with-panic

Add revert matchers
  • Loading branch information
fvictorio authored May 10, 2022
2 parents 0e3ab4e + bf50263 commit 2b30eba
Show file tree
Hide file tree
Showing 22 changed files with 2,359 additions and 98 deletions.
1 change: 1 addition & 0 deletions packages/hardhat-chai-matchers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"hardhat": "^2.0.0"
},
"dependencies": {
"@ethersproject/abi": "^5.1.2",
"@types/chai-as-promised": "^7.1.3",
"chai-as-promised": "^7.1.1"
}
Expand Down
9 changes: 9 additions & 0 deletions packages/hardhat-chai-matchers/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { CustomError } from "hardhat/common";

export class HardhatChaiMatchersDecodingError extends CustomError {
constructor(encodedData: string, type: string, parent: Error) {
const message = `There was an error decoding '${encodedData}' as a ${type}`;

super(message, parent);
}
}
12 changes: 10 additions & 2 deletions packages/hardhat-chai-matchers/src/hardhatChaiMatchers.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import "./types";
import { supportBigNumber } from "./bigNumber";
import { supportEmit } from "./emit";
import { supportReverted } from "./reverted";
import { supportHexEqual } from "./hexEqual";
import { supportProperAddress } from "./properAddress";
import { supportProperPrivateKey } from "./properPrivateKey";
import { supportChangeEtherBalance } from "./changeEtherBalance";
import { supportChangeEtherBalances } from "./changeEtherBalances";
import { supportReverted } from "./reverted/reverted";
import { supportRevertedWith } from "./reverted/revertedWith";
import { supportRevertedWithCustomError } from "./reverted/revertedWithCustomError";
import { supportRevertedWithPanic } from "./reverted/revertedWithPanic";
import { supportRevertedWithoutReasonString } from "./reverted/revertedWithoutReasonString";

export function hardhatChaiMatchers(
chai: Chai.ChaiStatic,
utils: Chai.ChaiUtils
) {
supportBigNumber(chai.Assertion, utils);
supportEmit(chai.Assertion);
supportReverted(chai.Assertion);
supportHexEqual(chai.Assertion);
supportProperAddress(chai.Assertion);
supportProperPrivateKey(chai.Assertion);
supportChangeEtherBalance(chai.Assertion);
supportChangeEtherBalances(chai.Assertion);
supportReverted(chai.Assertion);
supportRevertedWith(chai.Assertion);
supportRevertedWithCustomError(chai.Assertion, utils);
supportRevertedWithPanic(chai.Assertion);
supportRevertedWithoutReasonString(chai.Assertion);
}
2 changes: 2 additions & 0 deletions packages/hardhat-chai-matchers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { use } from "chai";
import chaiAsPromised from "chai-as-promised";

import "./types";

import { hardhatChaiMatchers } from "./hardhatChaiMatchers";

use(hardhatChaiMatchers);
Expand Down
31 changes: 0 additions & 31 deletions packages/hardhat-chai-matchers/src/reverted.ts

This file was deleted.

39 changes: 39 additions & 0 deletions packages/hardhat-chai-matchers/src/reverted/panic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { BigNumber } from "ethers";

export const PANIC_CODES = {
ASSERTION_ERROR: 0x1,
ARITHMETIC_UNDER_OR_OVERFLOW: 0x11,
DIVISION_BY_ZERO: 0x12,
ENUM_CONVERSION_OUT_OF_BOUNDS: 0x21,
INCORRECTLY_ENCODED_STORAGE_BYTE_ARRAY: 0x22,
POP_ON_EMPTY_ARRAY: 0x31,
ARRAY_ACCESS_OUT_OF_BOUNDS: 0x32,
TOO_MUCH_MEMORY_ALLOCATED: 0x41,
ZERO_INITIALIZED_VARIABLE: 0x51,
};

// copied from hardhat-core
export function panicErrorCodeToReason(
errorCode: BigNumber
): string | undefined {
switch (errorCode.toNumber()) {
case 0x1:
return "Assertion error";
case 0x11:
return "Arithmetic operation underflowed or overflowed outside of an unchecked block";
case 0x12:
return "Division or modulo division by zero";
case 0x21:
return "Tried to convert a value into an enum, but the value was too big or negative";
case 0x22:
return "Incorrectly encoded storage byte array";
case 0x31:
return ".pop() was called on an empty array";
case 0x32:
return "Array accessed at an out-of-bounds or negative index";
case 0x41:
return "Too much memory was allocated, or an array was created that is too large";
case 0x51:
return "Called a zero-initialized variable of internal function type";
}
}
118 changes: 118 additions & 0 deletions packages/hardhat-chai-matchers/src/reverted/reverted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { decodeReturnData, getReturnDataFromError } from "./utils";

export function supportReverted(Assertion: Chai.AssertionStatic) {
Assertion.addProperty("reverted", function (this: any) {
const subject: unknown = this._obj;

// Check if the received value can be linked to a transaction, and then
// get the receipt of that transaction and check its status.
//
// If the value doesn't correspond to a transaction, then the `reverted`
// assertion is false.
const onSuccess = (value: unknown) => {
if (isTransactionResponse(value) || typeof value === "string") {
const hash = typeof value === "string" ? value : value.hash;

if (!isValidTransactionHash(hash)) {
throw new TypeError(
`Expected a valid transaction hash, but got '${hash}'`
);
}

return getTransactionReceipt(hash).then((receipt) => {
this.assert(
receipt.status === 0,
"Expected transaction to be reverted",
"Expected transaction NOT to be reverted"
);
});
} else if (isTransactionReceipt(value)) {
const receipt = value;

this.assert(
receipt.status === 0,
"Expected transaction to be reverted",
"Expected transaction NOT to be reverted"
);
} else {
// If the subject of the assertion is not connected to a transaction
// (hash, receipt, etc.), then the assertion fails.
// Since we use `false` here, this means that `.not.to.be.reverted`
// assertions will pass instead of always throwing a validation error.
// This allows users to do things like:
// `expect(c.callStatic.f()).to.not.be.reverted`
this.assert(false, "Expected transaction to be reverted");
}
};

const onError = (error: any) => {
const returnData = getReturnDataFromError(error);
const decodedReturnData = decodeReturnData(returnData);

if (
decodedReturnData.kind === "Empty" ||
decodedReturnData.kind === "Custom"
) {
// in the negated case, if we can't decode the reason, we just indicate
// that the transaction didn't revert
this.assert(true, null, `Expected transaction NOT to be reverted`);
} else if (decodedReturnData.kind === "Error") {
this.assert(
true,
null,
`Expected transaction NOT to be reverted, but it reverted with reason '${decodedReturnData.reason}'`
);
} else if (decodedReturnData.kind === "Panic") {
this.assert(
true,
null,
`Expected transaction NOT to be reverted, but it reverted with panic code ${decodedReturnData.code.toHexString()} (${
decodedReturnData.description
})`
);
} else {
const _exhaustiveCheck: never = decodedReturnData;
}
};

// we use `Promise.resolve(subject)` so we can process both values and
// promises of values in the same way
const derivedPromise = Promise.resolve(subject).then(onSuccess, onError);

this.then = derivedPromise.then.bind(derivedPromise);
this.catch = derivedPromise.catch.bind(derivedPromise);

return this;
});
}

async function getTransactionReceipt(hash: string) {
const hre = await import("hardhat");

return hre.ethers.provider.getTransactionReceipt(hash);
}

function isTransactionResponse(x: unknown): x is { hash: string } {
if (typeof x === "object" && x !== null) {
return "hash" in x;
}

return false;
}

function isTransactionReceipt(x: unknown): x is { status: number } {
if (typeof x === "object" && x !== null && "status" in x) {
const status = (x as any).status;

// this means we only support ethers's receipts for now; adding support for
// raw receipts, where the status is an hexadecimal string, should be easy
// and we can do it if there's demand for that
return typeof status === "number";
}

return false;
}

function isValidTransactionHash(x: string): boolean {
return /0x[0-9a-fA-F]{64}/.test(x);
}
62 changes: 62 additions & 0 deletions packages/hardhat-chai-matchers/src/reverted/revertedWith.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { decodeReturnData, getReturnDataFromError } from "./utils";

export function supportRevertedWith(Assertion: Chai.AssertionStatic) {
Assertion.addMethod(
"revertedWith",
function (this: any, expectedReason: unknown) {
// validate expected reason
if (typeof expectedReason !== "string") {
throw new TypeError("Expected a string as the expected reason string");
}

const onSuccess = () => {
this.assert(
false,
`Expected transaction to be reverted with reason '${expectedReason}', but it didn't revert`
);
};

const onError = (error: any) => {
const returnData = getReturnDataFromError(error);
const decodedReturnData = decodeReturnData(returnData);

if (decodedReturnData.kind === "Empty") {
this.assert(
false,
`Expected transaction to be reverted with reason '${expectedReason}', but it reverted without a reason string`
);
} else if (decodedReturnData.kind === "Error") {
this.assert(
decodedReturnData.reason === expectedReason,
`Expected transaction to be reverted with reason '${expectedReason}', but it reverted with reason '${decodedReturnData.reason}'`,
`Expected transaction NOT to be reverted with reason '${expectedReason}', but it was`
);
} else if (decodedReturnData.kind === "Panic") {
this.assert(
false,
`Expected transaction to be reverted with reason '${expectedReason}', but it reverted with panic code ${decodedReturnData.code.toHexString()} (${
decodedReturnData.description
})`
);
} else if (decodedReturnData.kind === "Custom") {
this.assert(
false,
`Expected transaction to be reverted with reason '${expectedReason}', but it reverted with a custom error`
);
} else {
const _exhaustiveCheck: never = decodedReturnData;
}
};

const derivedPromise = Promise.resolve(this._obj).then(
onSuccess,
onError
);

this.then = derivedPromise.then.bind(derivedPromise);
this.catch = derivedPromise.catch.bind(derivedPromise);

return this;
}
);
}
Loading

0 comments on commit 2b30eba

Please sign in to comment.