diff --git a/.changeset/hip-adults-guess.md b/.changeset/hip-adults-guess.md new file mode 100644 index 000000000..04f4f97a7 --- /dev/null +++ b/.changeset/hip-adults-guess.md @@ -0,0 +1,6 @@ +--- +"@ethereum-waffle/mock-contract": patch +"@ethereum-waffle/chai": patch +--- + +Emit matcher improvement diff --git a/waffle-chai/src/matchers/emit.ts b/waffle-chai/src/matchers/emit.ts index 505aef258..ae0d35307 100644 --- a/waffle-chai/src/matchers/emit.ts +++ b/waffle-chai/src/matchers/emit.ts @@ -1,18 +1,38 @@ import {Contract, providers, utils} from 'ethers'; +import {keccak256, toUtf8Bytes} from 'ethers/lib/utils'; import {callPromise} from '../call-promise'; import {waitForPendingTransaction} from './misc/transaction'; import {supportWithArgs} from './withArgs'; import {supportWithNamedArgs} from './withNamedArgs'; export function supportEmit(Assertion: Chai.AssertionStatic) { - const filterLogsWithTopics = (logs: providers.Log[], topic: any, contractAddress: string) => + const filterLogsWithTopics = (logs: providers.Log[], topic: any, contractAddress?: string) => logs.filter((log) => log.topics.includes(topic)) - .filter((log) => log.address && log.address.toLowerCase() === contractAddress.toLowerCase()); + .filter((log) => + log.address && + (contractAddress === undefined || log.address.toLowerCase() === contractAddress.toLowerCase() + )); - Assertion.addMethod('emit', function (this: any, contract: Contract, eventName: string) { + const assertEmit = (assertion: any, frag: utils.EventFragment, isNegated: boolean, from?: string) => { + const topic = keccak256(toUtf8Bytes(frag.format())); + const receipt: providers.TransactionReceipt = assertion.txReceipt; + assertion.args = filterLogsWithTopics(receipt.logs, topic, from); + const isCurrentlyNegated = assertion.__flags.negate === true; + assertion.__flags.negate = isNegated; + assertion.assert(assertion.args.length > 0, + `Expected event "${frag.name}" to be emitted, but it wasn't`, + `Expected event "${frag.name}" NOT to be emitted, but it was` + ); + assertion.__flags.negate = isCurrentlyNegated; + }; + + Assertion.addMethod('emit', function (this: any, contractOrEventSig: Contract|string, eventName?: string) { if (typeof this._obj === 'string') { + if (typeof contractOrEventSig === 'string') { + throw new Error('The emit by event signature matcher must be called on a transaction'); + } // Handle specific case of using transaction hash to specify transaction. Done for backwards compatibility. - this.callPromise = waitForPendingTransaction(this._obj, contract.provider) + this.callPromise = waitForPendingTransaction(this._obj, contractOrEventSig.provider) .then(txReceipt => { this.txReceipt = txReceipt; }); @@ -20,51 +40,49 @@ export function supportEmit(Assertion: Chai.AssertionStatic) { callPromise(this); } const isNegated = this.__flags.negate === true; - this.callPromise = this.callPromise - .then(() => { - if (!('txReceipt' in this)) { - throw new Error('The emit matcher must be called on a transaction'); + this.callPromise = this.callPromise.then(() => { + if (!('txReceipt' in this)) { + throw new Error('The emit matcher must be called on a transaction'); + } + let eventFragment: utils.EventFragment | undefined; + if (typeof contractOrEventSig === 'string') { + try { + eventFragment = utils.EventFragment.from(contractOrEventSig); + } catch (e) { + throw new Error(`Invalid event signature: "${contractOrEventSig}"`); } - const receipt: providers.TransactionReceipt = this.txReceipt; - let eventFragment: utils.EventFragment | undefined; + assertEmit(this, eventFragment, isNegated); + } else if (eventName) { try { - eventFragment = contract.interface.getEvent(eventName); + eventFragment = contractOrEventSig.interface.getEvent(eventName); } catch (e) { - // ignore error + // ignore error } if (eventFragment === undefined) { this.assert( - isNegated, + this.__flags.negate, `Expected event "${eventName}" to be emitted, but it doesn't` + - ' exist in the contract. Please make sure you\'ve compiled' + - ' its latest version before running the test.', + ' exist in the contract. Please make sure you\'ve compiled' + + ' its latest version before running the test.', `WARNING: Expected event "${eventName}" NOT to be emitted.` + - ' The event wasn\'t emitted because it doesn\'t' + - ' exist in the contract. Please make sure you\'ve compiled' + - ' its latest version before running the test.', + ' The event wasn\'t emitted because it doesn\'t' + + ' exist in the contract. Please make sure you\'ve compiled' + + ' its latest version before running the test.', eventName, '' ); return; } + assertEmit(this, eventFragment, isNegated, contractOrEventSig.address); + + this.contract = contractOrEventSig; + } else { + throw new Error('The emit matcher must be called with a contract and an event name or an event signature'); + } + }); - const topic = contract.interface.getEventTopic(eventFragment); - this.args = filterLogsWithTopics(receipt.logs, topic, contract.address); - // As this callback will be resolved after the chain of matchers is finished, we need to - // know if the matcher has been negated or not. To simulate chai behaviour, we keep track of whether - // the matcher has been negated or not and set the internal chai flag __flags.negate to the same value. - // After the assertion is finished, we set the flag back to original value to not affect other assertions. - const isCurrentlyNegated = this.__flags.negate === true; - this.__flags.negate = isNegated; - this.assert(this.args.length > 0, - `Expected event "${eventName}" to be emitted, but it wasn't`, - `Expected event "${eventName}" NOT to be emitted, but it was` - ); - this.__flags.negate = isCurrentlyNegated; - }); this.then = this.callPromise.then.bind(this.callPromise); this.catch = this.callPromise.catch.bind(this.callPromise); - this.contract = contract; this.eventName = eventName; this.txMatcher = 'emit'; return this; diff --git a/waffle-chai/src/types.ts b/waffle-chai/src/types.ts index 2702a953e..3db5013d9 100644 --- a/waffle-chai/src/types.ts +++ b/waffle-chai/src/types.ts @@ -8,7 +8,7 @@ declare namespace Chai { interface Assertion extends LanguageChains, NumericComparison, TypeComparison { reverted: AsyncAssertion; revertedWith(reason: string | RegExp): RevertedWithAssertion; - emit(contract: any, eventName: string): EmitAssertion; + emit(contractOrEventSig: any, eventName?: string): EmitAssertion; properHex(length: number): void; hexEqual(other: string): void; properPrivateKey: void; diff --git a/waffle-chai/test/contracts/EventsProxy.ts b/waffle-chai/test/contracts/EventsProxy.ts new file mode 100644 index 000000000..1adb42cca --- /dev/null +++ b/waffle-chai/test/contracts/EventsProxy.ts @@ -0,0 +1,68 @@ +export const EVENTSPROXY_SOURCE = ` +pragma solidity ^0.8.3; + +import {Events} from "./Events.sol"; + +contract EventsProxy { + Events public events; + + constructor(Events _events) { + events = _events; + } + + function emitTwoDelegate() public { + // emit Two with a delegatecall to the events contract + (bool success, ) = address(events).delegatecall( + abi.encodeWithSignature("emitTwo()") + ); + require(success, "delegatecall failed"); + } + + function emitOne() public { + events.emitOne(); + } +}`; + +export const EVENTSPROXY_ABI = [ + { + inputs: [ + { + internalType: 'contract Events', + name: '_events', + type: 'address' + } + ], + stateMutability: 'nonpayable', + type: 'constructor' + }, + { + inputs: [], + name: 'emitOne', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [], + name: 'emitTwoDelegate', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [], + name: 'events', + outputs: [ + { + internalType: 'contract Events', + name: '', + type: 'address' + } + ], + stateMutability: 'view', + type: 'function' + } +]; + +// eslint-disable-next-line max-len +export const EVENTSPROXY_BYTECODE = '608060405234801561001057600080fd5b506040516105413803806105418339818101604052810190610032919061008d565b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050610111565b600081519050610087816100fa565b92915050565b60006020828403121561009f57600080fd5b60006100ad84828501610078565b91505092915050565b60006100c1826100da565b9050919050565b60006100d3826100b6565b9050919050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b610103816100c8565b811461010e57600080fd5b50565b610421806101206000396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c8063272b33af146100465780633f0e64ba14610050578063b5f8558c1461005a575b600080fd5b61004e610078565b005b6100586101c9565b005b61006261024b565b60405161006f91906102e9565b60405180910390f35b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166040516024016040516020818303038152906040527f34c10115000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff838183161783525050505060405161014291906102d2565b600060405180830381855af49150503d806000811461017d576040519150601f19603f3d011682016040523d82523d6000602084013e610182565b606091505b50509050806101c6576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016101bd90610304565b60405180910390fd5b50565b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16633f0e64ba6040518163ffffffff1660e01b8152600401600060405180830381600087803b15801561023157600080fd5b505af1158015610245573d6000803e3d6000fd5b50505050565b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b600061027a82610324565b610284818561032f565b935061029481856020860161038f565b80840191505092915050565b6102a98161036b565b82525050565b60006102bc60138361033a565b91506102c7826103c2565b602082019050919050565b60006102de828461026f565b915081905092915050565b60006020820190506102fe60008301846102a0565b92915050565b6000602082019050818103600083015261031d816102af565b9050919050565b600081519050919050565b600081905092915050565b600082825260208201905092915050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006103768261037d565b9050919050565b60006103888261034b565b9050919050565b60005b838110156103ad578082015181840152602081019050610392565b838111156103bc576000848401525b50505050565b7f64656c656761746563616c6c206661696c65640000000000000000000000000060008201525056fea26469706673582212200ce5c4d7353a3aed66c47b692095e41e9a8c9f4b6412620eabf464a32caff56a64736f6c63430008030033'; diff --git a/waffle-chai/test/matchers/eventsTest.ts b/waffle-chai/test/matchers/eventsTest.ts index f00591d60..797a54b54 100644 --- a/waffle-chai/test/matchers/eventsTest.ts +++ b/waffle-chai/test/matchers/eventsTest.ts @@ -1,9 +1,9 @@ import {expect, AssertionError} from 'chai'; import {Wallet, Contract, ContractFactory, BigNumber, ethers} from 'ethers'; import {EVENTS_ABI, EVENTS_BYTECODE} from '../contracts/Events'; +import {EVENTSPROXY_ABI, EVENTSPROXY_BYTECODE} from '../contracts/EventsProxy'; import type {TestProvider} from '@ethereum-waffle/provider'; - /** * Struct emitted in the Events contract, emitStruct method */ @@ -698,5 +698,50 @@ export const eventsWithNamedArgs = (provider: TestProvider) => { '{ Object (hash, value, ...) } to deeply equal { Object (hash, value, ...) }' ); }); + + it('Signature only - delegatecall', async () => { + const proxyFactory = new ContractFactory(EVENTSPROXY_ABI, EVENTSPROXY_BYTECODE, wallet); + const proxy = await proxyFactory.deploy(events.address); + + await expect(proxy.emitTwoDelegate()).to.emit('Two(uint256,string)'); + }); + + it('Signature only - regular event', async () => { + await expect(events.emitTwo()).to.emit('Two(uint256,string)'); + }); + + it('Signature only - negative', async () => { + await expect( + expect(events.emitTwo()).to.emit('One(uint256,string,bytes32)') + ).to.be.eventually.rejectedWith( + AssertionError, + 'Expected event "One" to be emitted, but it wasn\'t' + ); + }); + + it('Signature only - invalid event signature', async () => { + await expect( + expect(events.emitTwo()).to.emit('One') + ).to.be.eventually.rejectedWith( + Error, + 'Invalid event signature: "One"' + ); + }); + + it('Signature only - invalid args', async () => { + await expect( + expect(events.emitTwo()).to.emit(events) + ).to.be.eventually.rejectedWith( + Error, + 'The emit matcher must be called with a contract and an event name or an event signature' + ); + }); + + it('Signature only - Other contract event', async () => { + const proxyFactory = new ContractFactory(EVENTSPROXY_ABI, EVENTSPROXY_BYTECODE, wallet); + const proxy = await proxyFactory.deploy(events.address); + + await expect(proxy.emitOne()).to.emit('One(uint256,string,bytes32)'); + }); }); }; diff --git a/waffle-mock-contract/test/proxiedTest.ts b/waffle-mock-contract/test/proxiedTest.ts index 7d324f950..894be633e 100644 --- a/waffle-mock-contract/test/proxiedTest.ts +++ b/waffle-mock-contract/test/proxiedTest.ts @@ -57,5 +57,21 @@ export function mockContractProxiedTest(provider: MockProvider) { await mockContract.mock.read.returns(1); expect(await proxy.readCapped()).to.eq(1); }); + + it('calledOnContract with mock contract', async () => { + const {capContract, mockCounter} = await deploy(); + + await mockCounter.mock.read.returns(1); + await capContract.readCapped(); + expect('read').to.be.calledOnContract(mockCounter); + }); + + it('calledOnContractWith with mock contract', async () => { + const {capContract, mockCounter} = await deploy(); + + await mockCounter.mock.add.returns(1); + await capContract.addCapped(1); + expect('add').to.be.calledOnContractWith(mockCounter, [1]); + }); }); }