Skip to content

Commit 79a421f

Browse files
authoredMay 13, 2022
🎥 Reverted with custom error test args (#726)
1 parent 1e598c5 commit 79a421f

18 files changed

+408
-222
lines changed
 

Diff for: ‎.changeset/thin-items-hide.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ethereum-waffle/chai": patch
3+
---
4+
5+
Test args of custom errors with .withArgs matcher

Diff for: ‎docs/source/migration-guides.rst

+44-1
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ We updated the following dependencies:
263263

264264
- :code:`typechain` - bumped version from ^2.0.0 to ^9.0.0. Now every Waffle package uses the same version of the package. Also the package was moved to the :code:`peerDependencies` section - you now need to install :code:`typechain` manually when using Waffle.
265265
- :code:`ethers` - bumped version from to ^5.5.4. Now every Waffle package uses the same version of the package. Also the package was moved to the :code:`peerDependencies` section - you now need to install :code:`ethers` manually when using Waffle.
266-
- :code:`solc` - the package is used by :code:`waffle-compiler` package to provide the default option for compiling Soldity code. Was moved to the :code:`peerDependencies` section and has no version restrictions - you now have to install :code:`solc` manually when using Waffle.
266+
- :code:`solc` - the package is used by :code:`waffle-compiler` package to provide the default option for compiling Solidity code. Was moved to the :code:`peerDependencies` section and has no version restrictions - you now have to install :code:`solc` manually when using Waffle.
267267
- Deprecated :code:`ganache-core` package has been replaced with :code:`ganache` version ^7.0.3. It causes slight differences in the parameters of :code:`MockProvider` from :code:`@ethereum-waffle/provider`. Now the :code:`MockProvider` uses :code:`berlin` hardfork by default.
268268

269269
Changes to :code:`MockProvider` parameters
@@ -490,3 +490,46 @@ Note that in both cases you can use :code:`chai` negation :code:`not`. In a case
490490
.and.not
491491
.to.emit(complex, 'UnusedEvent') // This is negated
492492
.and.to.changeEtherBalances([sender, receiver], [-100, 100]) // This is negated as well
493+
494+
495+
Custom errors
496+
~~~~~~~~~~~~~
497+
498+
Custom errors were introduced in Solidity v0.8.4. It is a convenient and gas-efficient way to explain to users why an operation failed. Custom errors are defined in a similar way as events:
499+
500+
.. code-block:: solidity
501+
502+
// SPDX-License-Identifier: GPL-3.0
503+
pragma solidity ^0.8.4;
504+
505+
/// Insufficient balance for transfer. Needed `required` but only
506+
/// `available` available.
507+
/// @param available balance available.
508+
/// @param required requested amount to transfer.
509+
error InsufficientBalance(uint256 available, uint256 required);
510+
511+
contract TestToken {
512+
mapping(address => uint) balance;
513+
function transfer(address to, uint256 amount) public {
514+
if (amount > balance[msg.sender])
515+
// Error call using named parameters. Equivalent to
516+
// revert InsufficientBalance(balance[msg.sender], amount);
517+
revert InsufficientBalance({
518+
available: balance[msg.sender],
519+
required: amount
520+
});
521+
balance[msg.sender] -= amount;
522+
balance[to] += amount;
523+
}
524+
// ...
525+
}
526+
527+
528+
When using Waffle v4.0.0-alpha.* with Hardhat, you can test transactions being reverted with custom errors as well. Using the :code:`.revertedWith` matcher you can capture the custom error's name (:code:`expect(tx).to.be.revertedWith('InsufficientBalance')`). If you want to access arguments of a custom error you should use :code:`.withArgs` matcher after the :code:`.revertedWith` matcher.
529+
530+
.. code-block:: ts
531+
532+
await expect(token.transfer(receiver, 100))
533+
.to.be.revertedWith('InsufficientBalance')
534+
.withArgs(0, 100);
535+

Diff for: ‎waffle-chai/src/call-promise.ts

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {providers} from 'ethers';
2+
3+
type TransactionResponse = providers.TransactionResponse;
4+
type MaybePromise<T> = T | Promise<T>;
5+
6+
const isTransactionResponse = (response: any): response is TransactionResponse => {
7+
return 'wait' in response;
8+
};
9+
10+
/**
11+
* Takes a chai object (usually a `this` object) and adds a `promise` property to it.
12+
* Adds a `response` property to the chai object with the transaction response.
13+
* The promised is resolved when the transaction is mined.
14+
* Adds a `receipt` property to the chai object with the transaction receipt when the promise is resolved.
15+
* May be called on a chai object which contains any of these:
16+
* - a transaction response
17+
* - a promise which resolves to a transaction response
18+
* - a function that returns a transaction response
19+
* - a function that returns a promise which resolves to a transaction response
20+
* - same combinations as above but query instead of transaction.
21+
* Attention: some matchers require to be called on a transaction.
22+
*/
23+
export const callPromise = (chaiObj: any) => {
24+
if ('callPromise' in chaiObj) {
25+
return;
26+
}
27+
28+
const call = chaiObj._obj;
29+
let response: MaybePromise<any>;
30+
31+
if (typeof call === 'function') {
32+
response = call();
33+
} else {
34+
response = call;
35+
}
36+
37+
if (!('then' in response)) {
38+
if (isTransactionResponse(response)) {
39+
chaiObj.txResponse = response;
40+
chaiObj.callPromise = response.wait().then(txReceipt => {
41+
chaiObj.txReceipt = txReceipt;
42+
});
43+
} else {
44+
chaiObj.queryResponse = response;
45+
chaiObj.callPromise = Promise.resolve();
46+
}
47+
} else {
48+
chaiObj.callPromise = response.then(async (response: any) => {
49+
if (isTransactionResponse(response)) {
50+
chaiObj.txResponse = response;
51+
const txReceipt = await response.wait();
52+
chaiObj.txReceipt = txReceipt;
53+
} else {
54+
chaiObj.queryResponse = response;
55+
}
56+
});
57+
}
58+
59+
// Setting `then` and `catch` on the chai object to be compliant with the chai-aspromised library.
60+
chaiObj.then = chaiObj.callPromise.then.bind(chaiObj.callPromise);
61+
chaiObj.catch = chaiObj.callPromise.catch.bind(chaiObj.callPromise);
62+
};

Diff for: ‎waffle-chai/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {supportChangeTokenBalance} from './matchers/changeTokenBalance';
1515
import {supportChangeTokenBalances} from './matchers/changeTokenBalances';
1616
import {supportCalledOnContract} from './matchers/calledOnContract/calledOnContract';
1717
import {supportCalledOnContractWith} from './matchers/calledOnContract/calledOnContractWith';
18+
import {supportWithArgs} from './matchers/withArgs';
1819

1920
export function waffleChai(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
2021
supportBigNumber(chai.Assertion, utils);
@@ -33,4 +34,5 @@ export function waffleChai(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
3334
supportChangeTokenBalances(chai.Assertion);
3435
supportCalledOnContract(chai.Assertion);
3536
supportCalledOnContractWith(chai.Assertion);
37+
supportWithArgs(chai.Assertion);
3638
}

Diff for: ‎waffle-chai/src/matchers/changeBalance.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import {BigNumber, BigNumberish} from 'ethers';
22
import {Account, getAddressOf} from './misc/account';
33
import {getBalanceChange} from './changeEtherBalance';
4-
import {transactionPromise} from '../transaction-promise';
4+
import {callPromise} from '../call-promise';
55

66
export function supportChangeBalance(Assertion: Chai.AssertionStatic) {
77
Assertion.addMethod('changeBalance', function (
88
this: any,
99
account: Account,
1010
balanceChange: BigNumberish
1111
) {
12-
transactionPromise(this);
12+
callPromise(this);
1313
const isNegated = this.__flags.negate === true;
14-
const derivedPromise = this.txPromise.then(() => {
14+
const derivedPromise = this.callPromise.then(() => {
15+
if (!('txResponse' in this)) {
16+
throw new Error('The changeBalance matcher must be called on a transaction');
17+
}
1518
return Promise.all([
1619
getBalanceChange(this.txResponse, account, {includeFee: true}),
1720
getAddressOf(account)
@@ -31,7 +34,7 @@ export function supportChangeBalance(Assertion: Chai.AssertionStatic) {
3134
});
3235
this.then = derivedPromise.then.bind(derivedPromise);
3336
this.catch = derivedPromise.catch.bind(derivedPromise);
34-
this.txPromise = derivedPromise;
37+
this.callPromise = derivedPromise;
3538
return this;
3639
});
3740
}

Diff for: ‎waffle-chai/src/matchers/changeBalances.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {BigNumber, BigNumberish} from 'ethers';
2-
import {transactionPromise} from '../transaction-promise';
2+
import {callPromise} from '../call-promise';
33
import {getBalanceChanges} from './changeEtherBalances';
44
import {Account} from './misc/account';
55
import {getAddresses} from './misc/balance';
@@ -10,9 +10,12 @@ export function supportChangeBalances(Assertion: Chai.AssertionStatic) {
1010
accounts: Account[],
1111
balanceChanges: BigNumberish[]
1212
) {
13-
transactionPromise(this);
13+
callPromise(this);
1414
const isNegated = this.__flags.negate === true;
15-
const derivedPromise = this.txPromise.then(() => {
15+
const derivedPromise = this.callPromise.then(() => {
16+
if (!('txResponse' in this)) {
17+
throw new Error('The changeBalances matcher must be called on a transaction');
18+
}
1619
return Promise.all([
1720
getBalanceChanges(this.txResponse, accounts, {includeFee: true}),
1821
getAddresses(accounts)
@@ -34,7 +37,7 @@ export function supportChangeBalances(Assertion: Chai.AssertionStatic) {
3437
});
3538
this.then = derivedPromise.then.bind(derivedPromise);
3639
this.catch = derivedPromise.catch.bind(derivedPromise);
37-
this.txPromise = derivedPromise;
40+
this.callPromise = derivedPromise;
3841
return this;
3942
});
4043
}

Diff for: ‎waffle-chai/src/matchers/changeEtherBalance.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {BigNumber, BigNumberish, providers} from 'ethers';
2-
import {transactionPromise} from '../transaction-promise';
2+
import {callPromise} from '../call-promise';
33
import {ensure} from './calledOnContract/utils';
44
import {Account, getAddressOf} from './misc/account';
55
import {BalanceChangeOptions} from './misc/balance';
@@ -11,9 +11,12 @@ export function supportChangeEtherBalance(Assertion: Chai.AssertionStatic) {
1111
balanceChange: BigNumberish,
1212
options: BalanceChangeOptions
1313
) {
14-
transactionPromise(this);
14+
callPromise(this);
1515
const isNegated = this.__flags.negate === true;
16-
const derivedPromise = this.txPromise.then(() => {
16+
const derivedPromise = this.callPromise.then(() => {
17+
if (!('txResponse' in this)) {
18+
throw new Error('The changeEtherBalance matcher must be called on a transaction');
19+
}
1720
return Promise.all([
1821
getBalanceChange(this.txResponse, account, options),
1922
getAddressOf(account)
@@ -34,7 +37,7 @@ export function supportChangeEtherBalance(Assertion: Chai.AssertionStatic) {
3437
);
3538
this.then = derivedPromise.then.bind(derivedPromise);
3639
this.catch = derivedPromise.catch.bind(derivedPromise);
37-
this.txPromise = derivedPromise;
40+
this.callPromise = derivedPromise;
3841
return this;
3942
});
4043
}

Diff for: ‎waffle-chai/src/matchers/changeEtherBalances.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {BigNumber, BigNumberish, providers} from 'ethers';
2-
import {transactionPromise} from '../transaction-promise';
2+
import {callPromise} from '../call-promise';
33
import {getAddressOf, Account} from './misc/account';
44
import {BalanceChangeOptions, getAddresses, getBalances} from './misc/balance';
55

@@ -10,9 +10,12 @@ export function supportChangeEtherBalances(Assertion: Chai.AssertionStatic) {
1010
balanceChanges: BigNumberish[],
1111
options: BalanceChangeOptions
1212
) {
13-
transactionPromise(this);
13+
callPromise(this);
1414
const isNegated = this.__flags.negate === true;
15-
const derivedPromise = this.txPromise.then(() => {
15+
const derivedPromise = this.callPromise.then(() => {
16+
if (!('txResponse' in this)) {
17+
throw new Error('The changeEtherBalances matcher must be called on a transaction');
18+
}
1619
return Promise.all([
1720
getBalanceChanges(this.txResponse, accounts, options),
1821
getAddresses(accounts)
@@ -34,7 +37,7 @@ export function supportChangeEtherBalances(Assertion: Chai.AssertionStatic) {
3437
});
3538
this.then = derivedPromise.then.bind(derivedPromise);
3639
this.catch = derivedPromise.catch.bind(derivedPromise);
37-
this.txPromise = derivedPromise;
40+
this.callPromise = derivedPromise;
3841
return this;
3942
});
4043
}

Diff for: ‎waffle-chai/src/matchers/changeTokenBalance.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {BigNumber, BigNumberish, Contract, providers} from 'ethers';
2-
import {transactionPromise} from '../transaction-promise';
2+
import {callPromise} from '../call-promise';
33
import {Account, getAddressOf} from './misc/account';
44

55
export function supportChangeTokenBalance(Assertion: Chai.AssertionStatic) {
@@ -9,9 +9,12 @@ export function supportChangeTokenBalance(Assertion: Chai.AssertionStatic) {
99
account: Account,
1010
balanceChange: BigNumberish
1111
) {
12-
transactionPromise(this);
12+
callPromise(this);
1313
const isNegated = this.__flags.negate === true;
14-
const derivedPromise = this.txPromise.then(async () => {
14+
const derivedPromise = this.callPromise.then(async () => {
15+
if (!('txReceipt' in this)) {
16+
throw new Error('The changeTokenBalance matcher must be called on a transaction');
17+
}
1518
const address = await getAddressOf(account);
1619
const actualChanges = await getBalanceChange(this.txReceipt, token, address);
1720
return [actualChanges, address];
@@ -30,7 +33,7 @@ export function supportChangeTokenBalance(Assertion: Chai.AssertionStatic) {
3033
});
3134
this.then = derivedPromise.then.bind(derivedPromise);
3235
this.catch = derivedPromise.catch.bind(derivedPromise);
33-
this.txPromise = derivedPromise;
36+
this.callPromise = derivedPromise;
3437
return this;
3538
});
3639
}

Diff for: ‎waffle-chai/src/matchers/changeTokenBalances.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {BigNumber, BigNumberish, Contract, providers} from 'ethers';
2-
import {transactionPromise} from '../transaction-promise';
2+
import {callPromise} from '../call-promise';
33
import {Account, getAddressOf} from './misc/account';
44

55
export function supportChangeTokenBalances(Assertion: Chai.AssertionStatic) {
@@ -9,9 +9,12 @@ export function supportChangeTokenBalances(Assertion: Chai.AssertionStatic) {
99
accounts: Account[],
1010
balanceChanges: BigNumberish[]
1111
) {
12-
transactionPromise(this);
12+
callPromise(this);
1313
const isNegated = this.__flags.negate === true;
14-
const derivedPromise = this.txPromise.then(async () => {
14+
const derivedPromise = this.callPromise.then(async () => {
15+
if (!('txReceipt' in this)) {
16+
throw new Error('The changeTokenBalances matcher must be called on a transaction');
17+
}
1518
const addresses = await getAddresses(accounts);
1619
const actualChanges = await getBalanceChanges(this.txReceipt, token, addresses);
1720
return [actualChanges, addresses];
@@ -32,7 +35,7 @@ export function supportChangeTokenBalances(Assertion: Chai.AssertionStatic) {
3235
});
3336
this.then = derivedPromise.then.bind(derivedPromise);
3437
this.catch = derivedPromise.catch.bind(derivedPromise);
35-
this.txPromise = derivedPromise;
38+
this.callPromise = derivedPromise;
3639
return this;
3740
});
3841
}

Diff for: ‎waffle-chai/src/matchers/emit.ts

+12-73
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Contract, providers, utils} from 'ethers';
2-
import {transactionPromise} from '../transaction-promise';
2+
import {callPromise} from '../call-promise';
33
import {waitForPendingTransaction} from './misc/transaction';
44

55
export function supportEmit(Assertion: Chai.AssertionStatic) {
@@ -10,16 +10,19 @@ export function supportEmit(Assertion: Chai.AssertionStatic) {
1010
Assertion.addMethod('emit', function (this: any, contract: Contract, eventName: string) {
1111
if (typeof this._obj === 'string') {
1212
// Handle specific case of using transaction hash to specify transaction. Done for backwards compatibility.
13-
this.txPromise = waitForPendingTransaction(this._obj, contract.provider)
13+
this.callPromise = waitForPendingTransaction(this._obj, contract.provider)
1414
.then(txReceipt => {
1515
this.txReceipt = txReceipt;
1616
});
1717
} else {
18-
transactionPromise(this);
18+
callPromise(this);
1919
}
2020
const isNegated = this.__flags.negate === true;
21-
this.txPromise = this.txPromise
21+
this.callPromise = this.callPromise
2222
.then(() => {
23+
if (!('txReceipt' in this)) {
24+
throw new Error('The emit matcher must be called on a transaction');
25+
}
2326
const receipt: providers.TransactionReceipt = this.txReceipt;
2427
let eventFragment: utils.EventFragment | undefined;
2528
try {
@@ -44,88 +47,24 @@ export function supportEmit(Assertion: Chai.AssertionStatic) {
4447
}
4548

4649
const topic = contract.interface.getEventTopic(eventFragment);
47-
this.logs = filterLogsWithTopics(receipt.logs, topic, contract.address);
50+
this.args = filterLogsWithTopics(receipt.logs, topic, contract.address);
4851
// As this callback will be resolved after the chain of matchers is finished, we need to
4952
// know if the matcher has been negated or not. To simulate chai behaviour, we keep track of whether
5053
// the matcher has been negated or not and set the internal chai flag __flags.negate to the same value.
5154
// After the assertion is finished, we set the flag back to original value to not affect other assertions.
5255
const isCurrentlyNegated = this.__flags.negate === true;
5356
this.__flags.negate = isNegated;
54-
this.assert(this.logs.length > 0,
57+
this.assert(this.args.length > 0,
5558
`Expected event "${eventName}" to be emitted, but it wasn't`,
5659
`Expected event "${eventName}" NOT to be emitted, but it was`
5760
);
5861
this.__flags.negate = isCurrentlyNegated;
5962
});
60-
this.then = this.txPromise.then.bind(this.txPromise);
61-
this.catch = this.txPromise.catch.bind(this.txPromise);
63+
this.then = this.callPromise.then.bind(this.callPromise);
64+
this.catch = this.callPromise.catch.bind(this.callPromise);
6265
this.contract = contract;
6366
this.eventName = eventName;
64-
return this;
65-
});
66-
67-
const assertArgsArraysEqual = (context: any, expectedArgs: any[], log: any) => {
68-
const actualArgs = (context.contract.interface as utils.Interface).parseLog(log).args;
69-
context.assert(
70-
actualArgs.length === expectedArgs.length,
71-
`Expected "${context.eventName}" event to have ${expectedArgs.length} argument(s), ` +
72-
`but it has ${actualArgs.length}`,
73-
'Do not combine .not. with .withArgs()',
74-
expectedArgs.length,
75-
actualArgs.length
76-
);
77-
for (let index = 0; index < expectedArgs.length; index++) {
78-
if (expectedArgs[index]?.length !== undefined && typeof expectedArgs[index] !== 'string') {
79-
context.assert(
80-
actualArgs[index].length === expectedArgs[index].length,
81-
`Expected ${actualArgs[index]} to equal ${expectedArgs[index]}, ` +
82-
'but they have different lengths',
83-
'Do not combine .not. with .withArgs()'
84-
);
85-
for (let j = 0; j < expectedArgs[index].length; j++) {
86-
new Assertion(actualArgs[index][j]).equal(expectedArgs[index][j]);
87-
}
88-
} else {
89-
if (actualArgs[index].hash !== undefined && actualArgs[index]._isIndexed === true) {
90-
const expectedArgBytes = utils.isHexString(expectedArgs[index])
91-
? utils.arrayify(expectedArgs[index]) : utils.toUtf8Bytes(expectedArgs[index]);
92-
new Assertion(actualArgs[index].hash).to.be.oneOf(
93-
[expectedArgs[index], utils.keccak256(expectedArgBytes)]
94-
);
95-
} else {
96-
new Assertion(actualArgs[index]).equal(expectedArgs[index]);
97-
}
98-
}
99-
}
100-
};
101-
102-
const tryAssertArgsArraysEqual = (context: any, expectedArgs: any[], logs: any[]) => {
103-
if (logs.length === 1) return assertArgsArraysEqual(context, expectedArgs, logs[0]);
104-
for (const index in logs) {
105-
try {
106-
assertArgsArraysEqual(context, expectedArgs, logs[index]);
107-
return;
108-
} catch {}
109-
}
110-
context.assert(false,
111-
`Specified args not emitted in any of ${context.logs.length} emitted "${context.eventName}" events`,
112-
'Do not combine .not. with .withArgs()'
113-
);
114-
};
115-
116-
Assertion.addMethod('withArgs', function (this: any, ...expectedArgs: any[]) {
117-
if (!('txPromise' in this)) {
118-
throw new Error('withArgs() must be used after emit()');
119-
}
120-
const isNegated = this.__flags.negate === true;
121-
this.txPromise = this.txPromise.then(() => {
122-
const isCurrentlyNegated = this.__flags.negate === true;
123-
this.__flags.negate = isNegated;
124-
tryAssertArgsArraysEqual(this, expectedArgs, this.logs);
125-
this.__flags.negate = isCurrentlyNegated;
126-
});
127-
this.then = this.txPromise.then.bind(this.txPromise);
128-
this.catch = this.txPromise.catch.bind(this.txPromise);
67+
this.txMatcher = 'emit';
12968
return this;
13069
});
13170
}

Diff for: ‎waffle-chai/src/matchers/reverted.ts

+6-15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import {callPromise} from '../call-promise';
2+
13
export function supportReverted(Assertion: Chai.AssertionStatic) {
24
Assertion.addProperty('reverted', function (this: any) {
3-
const promise = this._obj;
5+
callPromise(this);
46
const onError = (error: any) => {
57
const message = (error instanceof Object && 'message' in error) ? error.message : JSON.stringify(error);
68
const isReverted = message.search('revert') >= 0;
@@ -24,20 +26,9 @@ export function supportReverted(Assertion: Chai.AssertionStatic) {
2426
'Transaction NOT reverted.'
2527
);
2628

27-
const onSuccess = (value: any) => {
28-
if (value && 'wait' in value) {
29-
// Sending the transaction succeeded, but we wait to see if it will revert on-chain.
30-
return value.wait().then((newValue: any) => {
31-
assertNotReverted();
32-
return newValue;
33-
}, onError);
34-
}
35-
assertNotReverted();
36-
return value;
37-
};
38-
const derivedPromise = promise.then(onSuccess, onError);
39-
this.then = derivedPromise.then.bind(derivedPromise);
40-
this.catch = derivedPromise.catch.bind(derivedPromise);
29+
this.callPromise = this.callPromise.then(assertNotReverted, onError);
30+
this.then = this.callPromise.then.bind(this.callPromise);
31+
this.catch = this.callPromise.catch.bind(this.callPromise);
4132
return this;
4233
});
4334
}

Diff for: ‎waffle-chai/src/matchers/revertedWith.ts

+21-54
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {decodeRevertString} from '@ethereum-waffle/provider';
2+
import {callPromise} from '../call-promise';
23

34
export function supportRevertedWith(Assertion: Chai.AssertionStatic) {
45
Assertion.addMethod('revertedWith', function (this: any, revertReason: string | RegExp) {
5-
const promise = this._obj;
6+
callPromise(this);
67

78
const assertNotReverted = () => this.assert(
89
false,
@@ -12,79 +13,45 @@ export function supportRevertedWith(Assertion: Chai.AssertionStatic) {
1213
'Transaction NOT reverted.'
1314
);
1415

15-
const onSuccess = (value: any) => {
16-
if (value && 'wait' in value) {
17-
// Sending the transaction succeeded, but we wait to see if it will revert on-chain.
18-
return value.wait().then((newValue: any) => {
19-
assertNotReverted();
20-
return newValue;
21-
}, onError);
22-
}
23-
assertNotReverted();
24-
return value;
25-
};
26-
2716
const onError = (error: any) => {
2817
const revertString = error?.receipt?.revertString ??
29-
decodeHardhatError(error) ??
18+
decodeHardhatError(error, this) ??
3019
decodeRevertString(error);
31-
if (revertString !== undefined) {
32-
const isReverted = revertReason instanceof RegExp
33-
? revertReason.test(revertString)
34-
: revertString === revertReason;
35-
this.assert(
36-
isReverted,
37-
`Expected transaction to be reverted with "${revertReason}", but other reason was found: "${revertString}"`,
38-
`Expected transaction NOT to be reverted with "${revertReason}"`,
39-
`Transaction reverted with "${revertReason}"`,
40-
error
41-
);
42-
return error;
43-
}
44-
45-
// See https://github.com/ethers-io/ethers.js/issues/829
46-
const isEstimateGasError =
47-
error instanceof Object &&
48-
error.code === 'UNPREDICTABLE_GAS_LIMIT' &&
49-
'error' in error;
50-
51-
if (isEstimateGasError) {
52-
error = error.error;
53-
}
54-
55-
const reasonsList = error.results && Object.values(error.results).map((o: any) => o.reason);
56-
const message = (error instanceof Object && 'message' in error) ? error.message : JSON.stringify(error);
57-
const isReverted = reasonsList
58-
? reasonsList.some((r: string) => revertReason instanceof RegExp ? revertReason.test(r) : r === revertReason)
59-
: message.includes('revert') &&
60-
(revertReason instanceof RegExp ? revertReason.test(message) : message.includes(revertReason));
61-
const isThrown = message.search('invalid opcode') >= 0 && revertReason === '';
6220

21+
const isReverted = revertReason instanceof RegExp
22+
? revertReason.test(revertString)
23+
: revertString === revertReason;
6324
this.assert(
64-
isReverted || isThrown,
65-
`Expected transaction to be reverted with "${revertReason}", but other exception was thrown: ${error}`,
25+
isReverted,
26+
`Expected transaction to be reverted with "${revertReason}", but other reason was found: "${revertString}"`,
6627
`Expected transaction NOT to be reverted with "${revertReason}"`,
67-
`Transaction reverted with "${revertReason}".`,
28+
`Transaction reverted with "${revertReason}"`,
6829
error
6930
);
31+
7032
return error;
7133
};
7234

73-
const derivedPromise = promise.then(onSuccess, onError);
74-
this.then = derivedPromise.then.bind(derivedPromise);
75-
this.catch = derivedPromise.catch.bind(derivedPromise);
35+
this.callPromise = this.callPromise.then(assertNotReverted, onError);
36+
this.then = this.callPromise.then.bind(this.callPromise);
37+
this.catch = this.callPromise.catch.bind(this.callPromise);
38+
this.txMatcher = 'revertedWith';
7639
return this;
7740
});
7841
}
7942

80-
const decodeHardhatError = (error: any) => {
43+
const decodeHardhatError = (error: any, context: any) => {
8144
const tryDecode = (error: any) => {
8245
const errorString = String(error);
8346
{
84-
const regexp = new RegExp('VM Exception while processing transaction: reverted with custom error \'(.*)\'');
47+
const regexp = /VM Exception while processing transaction: reverted with custom error '([a-zA-Z0-9]+)\((.*)\)'/g;
8548
const matches = regexp.exec(errorString);
8649
if (matches && matches.length >= 1) {
87-
return matches[1];
50+
// needs to be wrapped in list to be consistent with the emit matcher
51+
context.args = [JSON.parse(`[${matches[2]}]`)];
52+
const errorName = matches[1];
53+
context.txErrorName = errorName;
54+
return errorName;
8855
}
8956
}
9057
{

Diff for: ‎waffle-chai/src/matchers/withArgs.ts

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {utils} from 'ethers';
2+
3+
/**
4+
* Used for testing the arguments of events or custom errors.
5+
* Should be used after .emit or .revertedWith matchers respectively.
6+
*/
7+
export function supportWithArgs(Assertion: Chai.AssertionStatic) {
8+
const assertArgsArraysEqual = (context: any, expectedArgs: any[], arg: any) => {
9+
let actualArgs: utils.Result;
10+
let wrongNumberOfArgsMsg: string;
11+
if (context.txMatcher === 'emit') {
12+
actualArgs = (context.contract.interface as utils.Interface).parseLog(arg).args;
13+
wrongNumberOfArgsMsg = `Expected "${context.eventName}" event to have ${expectedArgs.length} argument(s), ` +
14+
`but it has ${actualArgs.length}`;
15+
} else if (context.txMatcher === 'revertedWith') {
16+
actualArgs = arg;
17+
wrongNumberOfArgsMsg = `Expected "${context.txErrorName}" event to have ${expectedArgs.length} argument(s), ` +
18+
`but it has ${actualArgs.length}`;
19+
} else {
20+
throw new Error('Unknown txMatcher');
21+
}
22+
context.assert(
23+
actualArgs.length === expectedArgs.length,
24+
wrongNumberOfArgsMsg,
25+
'Do not combine .not. with .withArgs()',
26+
expectedArgs.length,
27+
actualArgs.length
28+
);
29+
for (let index = 0; index < expectedArgs.length; index++) {
30+
if (expectedArgs[index]?.length !== undefined && typeof expectedArgs[index] !== 'string') {
31+
context.assert(
32+
actualArgs[index].length === expectedArgs[index].length,
33+
`Expected ${actualArgs[index]} to equal ${expectedArgs[index]}, ` +
34+
'but they have different lengths',
35+
'Do not combine .not. with .withArgs()'
36+
);
37+
for (let j = 0; j < expectedArgs[index].length; j++) {
38+
new Assertion(actualArgs[index][j]).equal(expectedArgs[index][j]);
39+
}
40+
} else {
41+
if (actualArgs[index].hash !== undefined && actualArgs[index]._isIndexed === true) {
42+
const expectedArgBytes = utils.isHexString(expectedArgs[index])
43+
? utils.arrayify(expectedArgs[index]) : utils.toUtf8Bytes(expectedArgs[index]);
44+
new Assertion(actualArgs[index].hash).to.be.oneOf(
45+
[expectedArgs[index], utils.keccak256(expectedArgBytes)]
46+
);
47+
} else {
48+
new Assertion(actualArgs[index]).equal(expectedArgs[index]);
49+
}
50+
}
51+
}
52+
};
53+
54+
const tryAssertArgsArraysEqual = (context: any, expectedArgs: any[], args: any[]) => {
55+
if (args.length === 1) return assertArgsArraysEqual(context, expectedArgs, args[0]);
56+
if (context.txMatcher !== 'emit') {
57+
throw new Error('Wrong format of arguments');
58+
}
59+
for (const index in args) {
60+
try {
61+
assertArgsArraysEqual(context, expectedArgs, args[index]);
62+
return;
63+
} catch {}
64+
}
65+
context.assert(false,
66+
`Specified args not emitted in any of ${context.args.length} emitted "${context.eventName}" events`,
67+
'Do not combine .not. with .withArgs()'
68+
);
69+
};
70+
71+
Assertion.addMethod('withArgs', function (this: any, ...expectedArgs: any[]) {
72+
if (!('txMatcher' in this) || !('callPromise' in this)) {
73+
throw new Error('withArgs() must be used after emit() or revertedWith()');
74+
}
75+
const isNegated = this.__flags.negate === true;
76+
this.callPromise = this.callPromise.then(() => {
77+
const isCurrentlyNegated = this.__flags.negate === true;
78+
this.__flags.negate = isNegated;
79+
tryAssertArgsArraysEqual(this, expectedArgs, this.args);
80+
this.__flags.negate = isCurrentlyNegated;
81+
});
82+
this.then = this.callPromise.then.bind(this.callPromise);
83+
this.catch = this.callPromise.catch.bind(this.callPromise);
84+
return this;
85+
});
86+
}

Diff for: ‎waffle-chai/src/transaction-promise.ts

-47
This file was deleted.

Diff for: ‎waffle-chai/src/types.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
declare namespace Chai {
88
interface Assertion extends LanguageChains, NumericComparison, TypeComparison {
99
reverted: AsyncAssertion;
10-
revertedWith(reason: string): AsyncAssertion;
10+
revertedWith(reason: string): RevertedWithAssertion;
1111
emit(contract: any, eventName: string): EmitAssertion;
1212
properHex(length: number): void;
1313
hexEqual(other: string): void;
@@ -42,4 +42,8 @@ declare namespace Chai {
4242
interface EmitAssertion extends AsyncAssertion {
4343
withArgs(...args: any[]): AsyncAssertion;
4444
}
45+
46+
interface RevertedWithAssertion extends AsyncAssertion {
47+
withArgs(...args: any[]): AsyncAssertion;
48+
}
4549
}

Diff for: ‎waffle-hardhat/contracts/CustomError.sol

+18-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
pragma solidity ^0.8.0;
22

3-
error CustomError(uint value);
3+
error One(uint value, string msg, bytes32 encoded);
4+
error Two(uint256[3] value, bytes32[2] encoded);
45

56
contract Matchers {
6-
function doRevertWithCustomError() public pure {
7-
revert CustomError(0);
7+
function doRevertWithOne() public pure {
8+
revert One(0, 'message', 0x00cFBbaF7DDB3a1476767101c12a0162e241fbAD2a0162e2410cFBbaF7162123);
9+
}
10+
11+
function doRevertWithTwo() public pure {
12+
revert Two(
13+
[
14+
uint256(1),
15+
uint256(2),
16+
uint256(3)
17+
],
18+
[
19+
bytes32(0x00cFBbaF7DDB3a1476767101c12a0162e241fbAD2a0162e2410cFBbaF7162123),
20+
bytes32(0x00cFBbaF7DDB3a1476767101c12a0162e241fbAD2a0162e2410cFBbaF7162124)
21+
]
22+
);
823
}
924
}

Diff for: ‎waffle-hardhat/test/reverted.test.ts

+105-4
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,113 @@ describe('INTEGRATION: Matchers: revertedWith', () => {
2424

2525
revertedWithTest(provider);
2626

27-
it('Handle custom error', async () => {
28-
await waffle.provider.send('hardhat_reset', []);
29-
const wallets = waffle.provider.getWallets();
27+
const deploy = async () => {
28+
const wallets = provider.getWallets();
3029
const wallet = wallets[0];
3130
const factory = new ContractFactory(CustomError.abi, CustomError.bytecode, wallet);
3231
const matchers = await factory.deploy();
33-
await expect(matchers.doRevertWithCustomError()).to.be.revertedWith('CustomError(0)');
32+
return matchers;
33+
};
34+
35+
// Custom errors are supported by hardhat, but you need to compile smart contract using hardhat as well
36+
// in order for custom errors to work properly. So those tests are hardhat specific.
37+
describe('Custom errors', () => {
38+
it('Revert success', async () => {
39+
const matchers = await deploy();
40+
await expect(matchers.doRevertWithOne()).to.be.revertedWith('One');
41+
});
42+
43+
it('Revert fail', async () => {
44+
const matchers = await deploy();
45+
await expect(expect(matchers.doRevertWithOne())
46+
.to.be.revertedWith('Two')
47+
).to.be.eventually.rejectedWith(
48+
'Expected transaction to be reverted with "Two", but other reason was found: "One"'
49+
);
50+
});
51+
52+
it('With args success', async () => {
53+
const matchers = await deploy();
54+
await expect(matchers.doRevertWithOne())
55+
.to.be.revertedWith('One')
56+
.withArgs(
57+
0,
58+
'message',
59+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123'
60+
);
61+
});
62+
63+
it('With args failure', async () => {
64+
const matchers = await deploy();
65+
await expect(expect(matchers.doRevertWithOne())
66+
.to.be.revertedWith('One')
67+
.withArgs(
68+
1,
69+
'message',
70+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123'
71+
)
72+
).to.be.eventually.rejectedWith('expected 0 to equal 1');
73+
await expect(expect(matchers.doRevertWithOne())
74+
.to.be.revertedWith('One')
75+
.withArgs(
76+
0,
77+
'messagr',
78+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123'
79+
)
80+
).to.be.eventually.rejectedWith('expected \'message\' to equal \'messagr\'');
81+
await expect(expect(matchers.doRevertWithOne())
82+
.to.be.revertedWith('One')
83+
.withArgs(
84+
0,
85+
'message',
86+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162124'
87+
)
88+
).to.be.eventually.rejectedWith('expected ' +
89+
'\'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123\'' +
90+
' to equal ' +
91+
'\'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162124\''
92+
);
93+
});
94+
95+
it('With array args success', async () => {
96+
const matchers = await deploy();
97+
await expect(matchers.doRevertWithTwo())
98+
.to.be.revertedWith('Two')
99+
.withArgs(
100+
[1, 2, 3],
101+
[
102+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123',
103+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162124'
104+
]
105+
);
106+
});
107+
108+
it('With array args failure', async () => {
109+
const matchers = await deploy();
110+
await expect(expect(matchers.doRevertWithTwo())
111+
.to.be.revertedWith('Two')
112+
.withArgs(
113+
[1, 2, 4],
114+
[
115+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123',
116+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162124'
117+
]
118+
)
119+
).to.be.eventually.rejectedWith('expected 3 to equal 4');
120+
await expect(expect(matchers.doRevertWithTwo())
121+
.to.be.revertedWith('Two')
122+
.withArgs(
123+
[1, 2, 3],
124+
[
125+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123',
126+
'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123'
127+
]
128+
)
129+
).to.be.eventually.rejectedWith('expected ' +
130+
'\'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162124\'' +
131+
' to equal ' +
132+
'\'0x00cfbbaf7ddb3a1476767101c12a0162e241fbad2a0162e2410cfbbaf7162123\''
133+
);
134+
});
34135
});
35136
});

0 commit comments

Comments
 (0)
Please sign in to comment.