Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6dfa35c
poll for `revert` and `waitForTransaction`
ChristopherDedominici Feb 24, 2026
f144be3
tmp: bug repro
ChristopherDedominici Feb 24, 2026
157d989
Merge branch 'fix-hardhat-ethers-chai-matchers' of github.com:NomicFo…
ChristopherDedominici Feb 24, 2026
005bf7d
Merge branch 'fix-hardhat-ethers-chai-matchers' of github.com:NomicFo…
ChristopherDedominici Feb 24, 2026
08a5c71
add unit tests for `waitForTransaction`
ChristopherDedominici Feb 24, 2026
670fd5b
Create honest-spies-play.md
ChristopherDedominici Feb 24, 2026
30b0331
Merge branch 'main' of github.com:NomicFoundation/hardhat into chai-m…
ChristopherDedominici Feb 24, 2026
e62710d
Merge branch 'chai-matchers-poll' of github.com:NomicFoundation/hardh…
ChristopherDedominici Feb 24, 2026
df7f24a
Add peer bump files
ChristopherDedominici Feb 24, 2026
df3c60f
update changeset for hh-chai
ChristopherDedominici Feb 24, 2026
6a5fe85
Update .changeset/brave-tigers-wave.md
ChristopherDedominici Mar 5, 2026
39b05e5
Merge remote-tracking branch 'origin/main' into chai-matchers-poll
kanej Mar 5, 2026
eb16806
chore: tweak changeset description
kanej Mar 5, 2026
4ddbc68
refactor: rename confirms variable to meet usage convention
kanej Mar 5, 2026
9cda25f
refactor: extract constant
kanej Mar 5, 2026
f2234be
refactor: tweak to add not case message
kanej Mar 5, 2026
d3eef66
fix: avoid race condition between poll and timeout
kanej Mar 5, 2026
fb64d99
needless whitespace for my clarity
kanej Mar 5, 2026
cfea545
Update v-next/hardhat-ethers/test/hardhat-ethers-provider.ts
kanej Mar 5, 2026
b8ed143
Merge branch 'main' of github.com:NomicFoundation/hardhat into chai-m…
ChristopherDedominici Mar 9, 2026
7b0535e
add PR bumps
ChristopherDedominici Mar 9, 2026
e52c968
guard against invalid and ambiguous tx hash strings in emit matcher
ChristopherDedominici Mar 18, 2026
3361fb0
extract `_isHardhatNetwork` from loop
ChristopherDedominici Mar 18, 2026
9acae43
honour confirms parameter in waitForTransaction
ChristopherDedominici Mar 18, 2026
cc0c382
lint:fix
ChristopherDedominici Mar 18, 2026
5fcc07a
Merge branch 'main' of github.com:NomicFoundation/hardhat into chai-m…
ChristopherDedominici Mar 18, 2026
a8baa1a
refactor: use util version of `parseBytes32String`
kanej Mar 25, 2026
838b6a2
Merge branch 'main' into chai-matchers-poll
kanej Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-tigers-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nomicfoundation/hardhat-ethers": patch
---

Added `HardhatEthersProvider.waitForTransaction` to provide polling support for `non-automining` networks ([#7952](https://github.com/NomicFoundation/hardhat/issues/7952)).
5 changes: 5 additions & 0 deletions .changeset/honest-spies-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nomicfoundation/hardhat-ethers-chai-matchers": patch
---

Added support to `hardhat-ethers-chai-matchers` for networks that do not support `automine` ([7952](https://github.com/NomicFoundation/hardhat/issues/7952)).
8 changes: 7 additions & 1 deletion .peer-bumps.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,11 @@
"v-next/hardhat/templates",
"v-next/config"
],
"bumps": []
"bumps": [
{
"package": "@nomicfoundation/hardhat-ethers-chai-matchers",
"peer": "@nomicfoundation/hardhat-ethers",
"reason": "Requires waitForTransaction implementation from hardhat-ethers for non-automining network support (#7952)"
}
]
}
48 changes: 48 additions & 0 deletions v-next/example-project/test/mocha/mocha-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,51 @@ describe("Rocket test", () => {
expect(await rocket.status()).to.equal("lift-off");
});
});

describe("Matchers without automining", () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TMP file to test in example project that the bug is fixed, it will be removed before merging

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind leaving it to be honest.

it("emit should wait for the tx to be mined", async () => {
const { ethers, provider } = await hre.network.connect();
Comment on lines +44 to +46
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description includes a TODO to “Drop the development helper in the example project”, but this change adds a new “Matchers without automining” section to the example project tests. If these tests are intended only as a temporary dev helper, consider removing them before merge (or update the PR description/TODO if they’re meant to stay as a permanent regression example).

Copilot uses AI. Check for mistakes.

const Rocket = await ethers.getContractFactory("Rocket");
const rocket = await Rocket.deploy("Apollo 11");

await provider.request({ method: "evm_setAutomine", params: [false] });

try {
const tx = await rocket.launch();
const emitPromise = expect(tx).to.emit(rocket, "LaunchWithoutArgs");

await provider.request({ method: "hardhat_mine", params: [] });
await emitPromise;
} finally {
await provider.request({ method: "evm_setAutomine", params: [true] });
}
});

it("revert should wait for the tx to be mined", async () => {
const { ethers, provider } = await hre.network.connect();

const FailingContract = await ethers.getContractFactory("FailingContract");
const failing = await FailingContract.deploy();

await provider.request({ method: "evm_setAutomine", params: [false] });

try {
// Send the tx manually because fail() is pure, which makes ethers
// use staticCall instead of sending a real transaction.
const [signer] = await ethers.getSigners();
const tx = await signer.sendTransaction({
to: await failing.getAddress(),
data: failing.interface.encodeFunctionData("fail"),
gasLimit: 1_000_000,
});

const revertPromise = expect(tx).to.be.revert(ethers);

await provider.request({ method: "hardhat_mine", params: [] });
await revertPromise;
} finally {
await provider.request({ method: "evm_setAutomine", params: [true] });
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import type { Transaction } from "ethers/transaction";

import util from "node:util";

import { isHash } from "@nomicfoundation/hardhat-utils/eth";
import { assert as chaiAssert, AssertionError } from "chai";

import { ASSERTION_ABORTED, EMIT_MATCHER } from "../constants.js";
import { assertArgsArraysEqual, assertIsNotNull } from "../utils/asserts.js";
import { buildAssert } from "../utils/build-assert.js";
import { preventAsyncMatcherChaining } from "../utils/prevent-chaining.js";

import { parseBytes32String } from "./reverted/utils.js";

export const EMIT_CALLED = "emitAssertionCalled";

async function waitForPendingTransaction(
Expand All @@ -32,7 +35,20 @@ async function waitForPendingTransaction(
chaiAssert.fail(`"${JSON.stringify(tx)}" is not a valid transaction`);
}

return provider.getTransactionReceipt(hash);
if (typeof tx === "string") {
if (!isHash(hash)) {
chaiAssert.fail(`Expected a valid transaction hash, but got "${hash}"`);
}

// If the input is a raw string that is also a valid bytes32-encoded
// string, it might not be a real tx hash. Do a one-shot check to
// avoid polling forever.
if (isBytes32String(hash)) {
return provider.getTransactionReceipt(hash);
}
}

return provider.waitForTransaction(hash);
}

export function supportEmit(
Expand Down Expand Up @@ -229,3 +245,12 @@ const tryAssertArgsArraysEqual = (
} emitted "${eventName}" events`,
);
};

function isBytes32String(v: string): boolean {
try {
parseBytes32String(v);
return true;
} catch {
return false;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types";

import { isHash } from "@nomicfoundation/hardhat-utils/eth";
import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex";
import { assert as chaiAssert } from "chai";

Expand Down Expand Up @@ -39,22 +40,40 @@ export function supportRevert(
if (isTransactionResponse(value) || typeof value === "string") {
const hash = typeof value === "string" ? value : value.hash;

if (!isValidTransactionHash(hash)) {
if (!isHash(hash)) {
chaiAssert.fail(
`Expected a valid transaction hash, but got "${hash}"`,
);
}

const receipt = await getTransactionReceipt(ethers, hash);
// If the input is a raw string that is also a valid bytes32-encoded
// string, it might not be a real tx hash. Do a one-shot check to
// avoid polling forever.
if (typeof value === "string" && isBytes32String(hash)) {
const oneShotReceipt =
await ethers.provider.getTransactionReceipt(hash);

if (oneShotReceipt === null) {
assert(
false,
"Expected transaction to be reverted",
"Expected transaction NOT to be reverted",
);

Comment thread
ChristopherDedominici marked this conversation as resolved.
if (receipt === null) {
// If the receipt is null, maybe the string is a bytes32 string
if (isBytes32String(hash)) {
assert(false, "Expected transaction to be reverted");
return;
}

assert(
oneShotReceipt.status === 0,
"Expected transaction to be reverted",
"Expected transaction NOT to be reverted",
);

return;
}

const receipt = await waitForTransactionReceipt(ethers, hash);

assertIsNotNull(
receipt,
"Transaction's receipt cannot be fetched from the network",
Expand Down Expand Up @@ -126,8 +145,8 @@ export function supportRevert(
);
}

async function getTransactionReceipt(ethers: HardhatEthers, hash: string) {
return ethers.provider.getTransactionReceipt(hash);
async function waitForTransactionReceipt(ethers: HardhatEthers, hash: string) {
return ethers.provider.waitForTransaction(hash);
}

function isTransactionResponse(x: unknown): x is { hash: string } {
Expand All @@ -151,10 +170,6 @@ function isTransactionReceipt(x: unknown): x is { status: number } {
return false;
}

function isValidTransactionHash(x: string): boolean {
return /0x[0-9a-fA-F]{64}/.test(x);
}

function isBytes32String(v: string): boolean {
try {
parseBytes32String(v);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function decodeReturnData(returnData: string): DecodedReturnData {

try {
chaiAssert.fail(
`There was an error decoding "${encodedReason}" as a "string. Reason: ${cause.message}"`,
`There was an error decoding "${encodedReason}" as "string". Reason: ${cause.message}`,
);
} catch (e) {
ensureError(e);
Expand Down
77 changes: 77 additions & 0 deletions v-next/hardhat-ethers-chai-matchers/test/matchers/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
OverrideEventContract,
} from "../helpers/contracts.js";
import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types";
import type { EthereumProvider } from "hardhat/types/providers";

import { before, beforeEach, describe, it } from "node:test";

Expand Down Expand Up @@ -896,6 +897,28 @@ describe(".to.emit (contract events)", { timeout: 60000 }, () => {
await expect(tx.hash).to.emit(contract, "WithoutArgs");
});

it("With an invalid transaction hash string", async () => {
await assertRejects(
() => expect("0x123").to.emit(contract, "WithoutArgs"),
(e) =>
e.message.includes(
'Expected a valid transaction hash, but got "0x123"',
),
"Expected invalid transaction hash error message",
);
});

it("With a bytes32-encoded string that is not a real tx hash", async () => {
await expect(
expect(
"0x3230323400000000000000000000000000000000000000000000000000000000",
).to.emit(contract, "WithoutArgs"),
).to.be.eventually.rejectedWith(
AssertionError,
"Transaction's receipt cannot be fetched from the network",
);
});

describe("When event is overloaded", () => {
it("should fail when the event name is ambiguous", async () => {
await expect(
Expand All @@ -919,5 +942,59 @@ describe(".to.emit (contract events)", { timeout: 60000 }, () => {
);
});
});

describe("When automining is disabled", () => {
let provider: EthereumProvider;

before(async () => {
({ provider } = await initEnvironment("events"));
});

Comment on lines +947 to +952
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block calls initEnvironment("events") a second time and uses that new provider to toggle automine/mine blocks, but the contracts/transactions in this suite were created with the ethers instance from the first initEnvironment call. If the second initEnvironment creates a separate in-process network/provider, these requests won’t affect the transactions under test, so the test may pass without actually exercising the non-automining behavior. Reuse the same provider obtained alongside ethers in the suite’s before() instead of creating a new environment here.

Suggested change
let provider: EthereumProvider;
before(async () => {
({ provider } = await initEnvironment("events"));
});

Copilot uses AI. Check for mistakes.
it("should wait for the tx to be mined and detect the event", async () => {
await provider.request({
method: "evm_setAutomine",
params: [false],
});

try {
const tx = contract.emitWithoutArgs();

const emitPromise = expect(tx).to.emit(contract, "WithoutArgs");

await provider.request({ method: "hardhat_mine", params: [] });

await emitPromise;
} finally {
await provider.request({
method: "evm_setAutomine",
params: [true],
});
}
});

it("should wait for the tx to be mined and verify event args", async () => {
await provider.request({
method: "evm_setAutomine",
params: [false],
});

try {
const tx = contract.emitUint(1);

const emitPromise = expect(tx)
.to.emit(contract, "WithUintArg")
.withArgs(1);

await provider.request({ method: "hardhat_mine", params: [] });

await emitPromise;
} finally {
await provider.request({
method: "evm_setAutomine",
params: [true],
});
}
});
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -470,5 +470,31 @@ describe("INTEGRATION: Revert", { timeout: 60000 }, () => {
expect.fail("Expected an exception but none was thrown");
});
});

describe("When automining is disabled", () => {
it("should wait for the tx to be mined and detect the revert", async () => {
await provider.request({
method: "evm_setAutomine",
params: [false],
});

try {
const tx = await matchers.revertsWithoutReason({
gasLimit: 1_000_000,
});

const revertPromise = expect(tx).to.be.revert(ethers);

await provider.request({ method: "hardhat_mine", params: [] });

await revertPromise;
} finally {
await provider.request({
method: "evm_setAutomine",
params: [true],
});
}
});
});
}
});
Loading
Loading