diff --git a/CHANGELOG.md b/CHANGELOG.md index 203f9797643..40a8628b319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1461,3 +1461,13 @@ should use 4.0.1-alpha.0 for testing. - Bug fix of `checkNetwork` in ENS (#5988) ## [Unreleased] + +### Added + +#### web3-eth-contract + +- Added support for `getPastEvents` method to filter `allEvents` and specific event (#6010) + +#### web3-types + +- Added `filters` param to the `Filter` type (#6010) diff --git a/packages/web3-eth-contract/CHANGELOG.md b/packages/web3-eth-contract/CHANGELOG.md index bef63e3bbbb..6b23870bec4 100644 --- a/packages/web3-eth-contract/CHANGELOG.md +++ b/packages/web3-eth-contract/CHANGELOG.md @@ -254,3 +254,7 @@ const transactionHash = receipt.transactionHash; - `data` was removed as a property of `ContractOptions` type (#5915) ## [Unreleased] + +### Added + +- Added support for `getPastEvents` method to filter `allEvents` and specific event (#6010) diff --git a/packages/web3-eth-contract/src/contract.ts b/packages/web3-eth-contract/src/contract.ts index 36ec032045f..cc5ff2a9530 100644 --- a/packages/web3-eth-contract/src/contract.ts +++ b/packages/web3-eth-contract/src/contract.ts @@ -68,6 +68,7 @@ import { PayableCallOptions, DataFormat, DEFAULT_RETURN_FORMAT, + Numbers, } from 'web3-types'; import { format, isDataFormat, toChecksumAddress } from 'web3-utils'; import { @@ -702,7 +703,7 @@ export class Contract ): Promise<(string | EventLog)[]> { const eventName = typeof param1 === 'string' ? param1 : 'allEvents'; - const filter = + const options = // eslint-disable-next-line no-nested-ternary typeof param1 !== 'string' && !isDataFormat(param1) ? param1 @@ -727,19 +728,36 @@ export class Contract if (!abi) { throw new Web3ContractError(`Event ${eventName} not found.`); } - const { fromBlock, toBlock, topics, address } = encodeEventABI( this.options, abi, - filter ?? {}, + options ?? {}, ); - const logs = await getLogs(this, { fromBlock, toBlock, topics, address }, returnFormat); - return logs.map(log => + const decodedLogs = logs.map(log => typeof log === 'string' ? log : decodeEventABI(abi, log as LogsInput, this._jsonInterface, returnFormat), ); + + const filter = options?.filter ?? {}; + const filterKeys = Object.keys(filter); + return eventName === 'allEvents' && filterKeys.length > 0 + ? decodedLogs.filter(log => + typeof log === 'string' + ? true + : filterKeys.every((k: string) => + Array.isArray(filter[k]) + ? (filter[k] as Numbers[]).some( + (v: Numbers) => + String(log.returnValues[k]).toUpperCase() === + String(v).toUpperCase(), + ) + : String(log.returnValues[k]).toUpperCase() === + String(filter[k]).toUpperCase(), + ), + ) + : decodedLogs; } private _parseAndSetAddress(value?: Address, returnFormat: DataFormat = DEFAULT_RETURN_FORMAT) { diff --git a/packages/web3-eth-contract/src/encoding.ts b/packages/web3-eth-contract/src/encoding.ts index d40c5a85a0a..e177e7574db 100644 --- a/packages/web3-eth-contract/src/encoding.ts +++ b/packages/web3-eth-contract/src/encoding.ts @@ -22,11 +22,9 @@ import { AbiEventFragment, AbiFunctionFragment, LogsInput, - BlockNumberOrTag, Filter, HexString, Topic, - Numbers, FMT_NUMBER, FMT_BYTES, DataFormat, @@ -51,27 +49,15 @@ import { Web3ContractError } from 'web3-errors'; // eslint-disable-next-line import/no-cycle import { ContractOptions, ContractAbiWithSignature, EventLog } from './types'; +type Writeable = { -readonly [P in keyof T]: T[P] }; export const encodeEventABI = ( { address }: ContractOptions, event: AbiEventFragment & { signature: string }, - options?: { - fromBlock?: BlockNumberOrTag; - toBlock?: BlockNumberOrTag; - filter?: Filter; - // Using "null" type intentionally to match specifications - // eslint-disable-next-line @typescript-eslint/ban-types - topics?: (null | Topic | Topic[])[]; - }, + options?: Filter, ) => { - const opts: { - filter: Filter; - fromBlock?: Numbers; - toBlock?: Numbers; - topics?: (Topic | Topic[])[]; - address?: HexString; - } = { - filter: options?.filter ?? {}, - }; + const topics = options?.topics; + const filter = options?.filter ?? {}; + const opts: Writeable = {}; if (!isNullish(options?.fromBlock)) { opts.fromBlock = format(blockSchema.properties.number, options?.fromBlock, { @@ -86,11 +72,10 @@ export const encodeEventABI = ( }); } - if (options?.topics && Array.isArray(options.topics)) { - opts.topics = [...options.topics].filter(Boolean) as Topic[]; + if (topics && Array.isArray(topics)) { + opts.topics = [...topics] as Topic[]; } else { opts.topics = []; - // add event signature if (event && !event.anonymous && event.name !== 'ALLEVENTS') { opts.topics.push( @@ -105,19 +90,20 @@ export const encodeEventABI = ( continue; } - const value = opts.filter[input.name as keyof Filter]; - + const value = filter[input.name]; if (!value) { + // eslint-disable-next-line no-null/no-null + opts.topics.push(null); continue; } // TODO: https://github.com/ethereum/web3.js/issues/344 // TODO: deal properly with components if (Array.isArray(value)) { - opts.topics.push(...value.map(v => encodeParameter(input.type, v))); + opts.topics.push(value.map(v => encodeParameter(input.type, v))); + } else { + opts.topics.push(encodeParameter(input.type, value)); } - - opts.topics.push(encodeParameter(input.type, value)); } } } diff --git a/packages/web3-eth-contract/src/log_subscription.ts b/packages/web3-eth-contract/src/log_subscription.ts index 1863f891c4d..f524854968d 100644 --- a/packages/web3-eth-contract/src/log_subscription.ts +++ b/packages/web3-eth-contract/src/log_subscription.ts @@ -83,7 +83,8 @@ export class LogsSubscription extends Web3Subscription< data: EventLog; changed: EventLog & { removed: true }; }, - { address?: HexString; topics?: (Topic | Topic[])[]; abi: AbiEventFragment } + // eslint-disable-next-line @typescript-eslint/ban-types + { address?: HexString; topics?: (Topic | Topic[] | null)[]; abi: AbiEventFragment } > { /** * Address of tye contract @@ -93,7 +94,8 @@ export class LogsSubscription extends Web3Subscription< /** * The list of topics subscribed */ - public readonly topics?: (Topic | Topic[])[]; + // eslint-disable-next-line @typescript-eslint/ban-types + public readonly topics?: (Topic | Topic[] | null)[]; /** * The {@doclink glossary/json_interface | JSON Interface} of the event. @@ -105,7 +107,8 @@ export class LogsSubscription extends Web3Subscription< public constructor( args: { address?: HexString; - topics?: (Topic | Topic[])[]; + // eslint-disable-next-line @typescript-eslint/ban-types + topics?: (Topic | Topic[] | null)[]; abi: AbiEventFragment & { signature: HexString }; jsonInterface: ContractAbiWithSignature; }, diff --git a/packages/web3-eth-contract/test/integration/contract_filter_events.test.ts b/packages/web3-eth-contract/test/integration/contract_filter_events.test.ts new file mode 100644 index 00000000000..61b392815f1 --- /dev/null +++ b/packages/web3-eth-contract/test/integration/contract_filter_events.test.ts @@ -0,0 +1,242 @@ +/* +This file is part of web3.js. + +web3.js is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +web3.js is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with web3.js. If not, see . +*/ + +import { toBigInt } from 'web3-utils'; +import { Contract } from '../../src'; +import { ERC20TokenAbi, ERC20TokenBytecode } from '../shared_fixtures/build/ERC20Token'; +import { BasicAbi, BasicBytecode } from '../shared_fixtures/build/Basic'; +import { + getSystemTestProvider, + createTempAccount, + createNewAccount, +} from '../fixtures/system_test_utils'; +import { EventLog } from '../../src/types'; + +const initialSupply = BigInt('5000000000'); + +describe('contract getPastEvent filter', () => { + describe('erc20', () => { + let contract: Contract; + let contractDeployed: Contract; + let deployOptions: Record; + let sendOptions: Record; + let mainAcc: Record; + let toAcc1: Record; + let toAcc2: Record; + let toAcc3: Record; + + beforeAll(async () => { + contract = new Contract(ERC20TokenAbi, undefined, { + provider: getSystemTestProvider(), + }); + + deployOptions = { + data: ERC20TokenBytecode, + arguments: [initialSupply], + }; + mainAcc = await createTempAccount(); + sendOptions = { from: mainAcc.address, gas: '10000000' }; + contractDeployed = await contract.deploy(deployOptions).send(sendOptions); + toAcc1 = await createNewAccount(); + toAcc2 = await createNewAccount(); + toAcc3 = await createNewAccount(); + const value = BigInt(10); + await contractDeployed.methods.transfer(toAcc1.address, value).send(sendOptions); + await contractDeployed.methods.transfer(toAcc2.address, value).send(sendOptions); + await contractDeployed.methods.transfer(toAcc3.address, value).send(sendOptions); + }); + + it('should filter one event by address with event name and filter param', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents('Transfer', { + fromBlock: 'earliest', + filter: { + to: toAcc2.address, + }, + })) as unknown as EventLog[]; + expect(res[0]).toBeDefined(); + expect((res[0]?.returnValues?.to as string).toUpperCase()).toBe( + toAcc2.address.toUpperCase(), + ); + }); + it('should filter one event by address without event name and filter param', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents({ + fromBlock: 'earliest', + filter: { + to: toAcc2.address, + }, + })) as unknown as EventLog[]; + expect(res[0]).toBeDefined(); + expect((res[0]?.returnValues?.to as string).toUpperCase()).toBe( + toAcc2.address.toUpperCase(), + ); + }); + it('should filter one event by address with event name allEvents and filter param', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents('allEvents', { + fromBlock: 'earliest', + filter: { + to: toAcc2.address, + }, + })) as unknown as EventLog[]; + expect(res[0]).toBeDefined(); + expect((res[0]?.returnValues?.to as string).toUpperCase()).toBe( + toAcc2.address.toUpperCase(), + ); + }); + it('should filter few event by addresses array', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents('Transfer', { + fromBlock: 'earliest', + filter: { + to: [toAcc2.address, toAcc3.address], + }, + })) as unknown as EventLog[]; + expect(res).toHaveLength(2); + + const event2 = res.filter( + e => + String(e.returnValues.to).toUpperCase() === + String(toAcc2.address).toUpperCase(), + )[0]; + const event3 = res.filter( + e => + String(e.returnValues.to).toUpperCase() === + String(toAcc3.address).toUpperCase(), + )[0]; + + expect(event2).toBeDefined(); + expect(event3).toBeDefined(); + expect((event2?.returnValues?.to as string).toUpperCase()).toBe( + toAcc2.address.toUpperCase(), + ); + expect((event3?.returnValues?.to as string).toUpperCase()).toBe( + toAcc3.address.toUpperCase(), + ); + }); + it('should filter few event by addresses array without eventName', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents({ + fromBlock: 'earliest', + filter: { + to: [toAcc2.address, toAcc3.address], + }, + })) as unknown as EventLog[]; + expect(res).toHaveLength(2); + + const event2 = res.filter( + e => + String(e.returnValues.to).toUpperCase() === + String(toAcc2.address).toUpperCase(), + )[0]; + const event3 = res.filter( + e => + String(e.returnValues.to).toUpperCase() === + String(toAcc3.address).toUpperCase(), + )[0]; + + expect(event2).toBeDefined(); + expect(event3).toBeDefined(); + expect((event2?.returnValues?.to as string).toUpperCase()).toBe( + toAcc2.address.toUpperCase(), + ); + expect((event3?.returnValues?.to as string).toUpperCase()).toBe( + toAcc3.address.toUpperCase(), + ); + }); + }); + describe('basic', () => { + let contract: Contract; + let contractDeployed: Contract; + let deployOptions: Record; + let sendOptions: Record; + let mainAcc: Record; + + beforeAll(async () => { + contract = new Contract(BasicAbi, undefined, { + provider: getSystemTestProvider(), + }); + + deployOptions = { + data: BasicBytecode, + arguments: [123, '123'], + }; + mainAcc = await createTempAccount(); + sendOptions = { from: mainAcc.address, gas: '10000000' }; + contractDeployed = await contract.deploy(deployOptions).send(sendOptions); + await contractDeployed.methods + .firesMultiValueIndexedEvent('str1', 1, true) + .send(sendOptions); + await contractDeployed.methods + .firesMultiValueIndexedEvent('str2', 2, false) + .send(sendOptions); + await contractDeployed.methods + .firesMultiValueIndexedEvent('str3', 3, true) + .send(sendOptions); + }); + + it('should filter one event by address with event name and filter param', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents( + 'MultiValueIndexedEvent', + { + fromBlock: 'earliest', + filter: { + val: 2, + }, + }, + )) as unknown as EventLog[]; + expect(res[0]).toBeDefined(); + expect(res[0]?.returnValues?.val).toBe(toBigInt(2)); + }); + it('should filter few event by numbers array', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents( + 'MultiValueIndexedEvent', + { + fromBlock: 'earliest', + filter: { + val: [2, 3], + }, + }, + )) as unknown as EventLog[]; + expect(res).toHaveLength(2); + + const event2 = res.filter(e => e.returnValues.val === toBigInt(2))[0]; + const event3 = res.filter(e => e.returnValues.val === toBigInt(3))[0]; + + expect(event2).toBeDefined(); + expect(event3).toBeDefined(); + expect(event2?.returnValues?.val).toBe(toBigInt(2)); + expect(event3?.returnValues?.val).toBe(toBigInt(3)); + }); + it('should filter few event by bool array', async () => { + const res: EventLog[] = (await contractDeployed.getPastEvents( + 'MultiValueIndexedEvent', + { + fromBlock: 'earliest', + filter: { + flag: [true], + }, + }, + )) as unknown as EventLog[]; + expect(res).toHaveLength(2); + + const event1 = res.filter(e => e.returnValues.val === toBigInt(1))[0]; + const event3 = res.filter(e => e.returnValues.val === toBigInt(3))[0]; + + expect(event1).toBeDefined(); + expect(event3).toBeDefined(); + expect(event1?.returnValues?.val).toBe(toBigInt(1)); + expect(event3?.returnValues?.val).toBe(toBigInt(3)); + }); + }); +}); diff --git a/packages/web3-eth-contract/test/unit/contract.test.ts b/packages/web3-eth-contract/test/unit/contract.test.ts index abf00abbaa4..b097dba33ec 100644 --- a/packages/web3-eth-contract/test/unit/contract.test.ts +++ b/packages/web3-eth-contract/test/unit/contract.test.ts @@ -468,6 +468,47 @@ describe('Contract', () => { spyGetLogs.mockClear(); }); + it('getPastEvents with filter by topics should work', async () => { + const contract = new Contract(GreeterAbi); + + const spyTx = jest.spyOn(eth, 'sendTransaction').mockImplementation(() => { + const newContract = contract.clone(); + newContract.options.address = deployedAddr; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Promise.resolve(newContract) as any; + }); + + const spyGetLogs = jest + .spyOn(eth, 'getLogs') + .mockImplementation((_objInstance, _params) => { + expect(_params.address).toStrictEqual(deployedAddr.toLocaleLowerCase()); + expect(_params.fromBlock).toStrictEqual(getLogsData.request.fromBlock); + expect(_params.toBlock).toStrictEqual(getLogsData.request.toBlock); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Promise.resolve([getLogsData.response[0]]) as any; + }); + + const deployedContract = await contract + .deploy({ + data: GreeterBytecode, + arguments: ['My Greeting'], + }) + .send(sendOptions); + + const fromBlock = 'earliest'; + const toBlock = 'latest'; + const pastEvent = await deployedContract.getPastEvents(getPastEventsData.event as any, { + fromBlock, + toBlock, + topics: ['0x7d7846723bda52976e0286c6efffee937ee9f76817a867ec70531ad29fb1fc0e'], + }); + + expect(pastEvent).toStrictEqual(getPastEventsData.response); + spyTx.mockClear(); + spyGetLogs.mockClear(); + }); + it('getPastEvents for all events should work', async () => { const contract = new Contract(GreeterAbi); @@ -504,6 +545,111 @@ describe('Contract', () => { spyGetLogs.mockClear(); }); + it('getPastEvents for all events with filter should work', async () => { + const contract = new Contract(GreeterAbi); + + const spyTx = jest.spyOn(eth, 'sendTransaction').mockImplementation(() => { + const newContract = contract.clone(); + newContract.options.address = deployedAddr; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Promise.resolve(newContract) as any; + }); + + const spyGetLogs = jest + .spyOn(eth, 'getLogs') + .mockImplementation((_objInstance, _params) => { + expect(_params.address).toStrictEqual(deployedAddr.toLocaleLowerCase()); + expect(_params.fromBlock).toBeUndefined(); + expect(_params.toBlock).toBeUndefined(); + expect(_params.topics).toBeUndefined(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Promise.resolve(AllGetPastEventsData.getLogsData) as any; // AllGetPastEventsData.getLogsData data test is for: assume two transactions sent to contract with contractInstance.methods.setGreeting("Hello") and contractInstance.methods.setGreeting("Another Greeting") + }); + + const deployedContract = await contract + .deploy({ + data: GreeterBytecode, + arguments: ['My Greeting'], + }) + .send(sendOptions); + + const pastEvent = await deployedContract.getPastEvents('allEvents', { + filter: { + greeting: 'Another Greeting', + }, + }); + + expect(pastEvent).toHaveLength(1); + expect(pastEvent[0]).toStrictEqual(AllGetPastEventsData.response[1]); + + const pastEventWithoutEventName = await deployedContract.getPastEvents({ + filter: { + greeting: 'Another Greeting', + }, + }); + + expect(pastEventWithoutEventName).toHaveLength(1); + expect(pastEventWithoutEventName[0]).toStrictEqual(AllGetPastEventsData.response[1]); + + const pastEventFilterArray = await deployedContract.getPastEvents({ + filter: { + greeting: ['Another Greeting'], + }, + }); + + expect(pastEventFilterArray).toHaveLength(1); + expect(pastEventFilterArray[0]).toStrictEqual(AllGetPastEventsData.response[1]); + + const pastEventFilterWithIncorrectParam = await deployedContract.getPastEvents({ + filter: { + incorrectParam: 'test', + }, + }); + expect(pastEventFilterWithIncorrectParam).toHaveLength(0); + + spyTx.mockClear(); + spyGetLogs.mockClear(); + }); + + it('getPastEvents for all events with filter by topics should work', async () => { + const contract = new Contract(GreeterAbi); + + const spyTx = jest.spyOn(eth, 'sendTransaction').mockImplementation(() => { + const newContract = contract.clone(); + newContract.options.address = deployedAddr; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Promise.resolve(newContract) as any; + }); + + const spyGetLogs = jest + .spyOn(eth, 'getLogs') + .mockImplementation((_objInstance, _params) => { + expect(_params.address).toStrictEqual(deployedAddr.toLocaleLowerCase()); + expect(_params.fromBlock).toBeUndefined(); + expect(_params.toBlock).toBeUndefined(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Promise.resolve([AllGetPastEventsData.getLogsData[1]]) as any; // AllGetPastEventsData.getLogsData data test is for: assume two transactions sent to contract with contractInstance.methods.setGreeting("Hello") and contractInstance.methods.setGreeting("Another Greeting") + }); + + const deployedContract = await contract + .deploy({ + data: GreeterBytecode, + arguments: ['My Greeting'], + }) + .send(sendOptions); + + const pastEvent = await deployedContract.getPastEvents({ + topics: ['0x7d7846723bda52976e0286c6efffee937ee9f76817a867ec70531ad29fb1fc0e'], + }); + expect(pastEvent).toHaveLength(1); + expect(pastEvent[0]).toStrictEqual(AllGetPastEventsData.response[1]); + + spyTx.mockClear(); + spyGetLogs.mockClear(); + }); + it('estimateGas should work', async () => { const arg = 'Hello'; diff --git a/packages/web3-types/CHANGELOG.md b/packages/web3-types/CHANGELOG.md index 2f75f54a7ab..1f4a1fc050c 100644 --- a/packages/web3-types/CHANGELOG.md +++ b/packages/web3-types/CHANGELOG.md @@ -93,3 +93,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The types `ContractInitOptions`, `NonPayableCallOptions` and `PayableCallOptions` are moved from `web3-eth-contract`. (#5993) ## [Unreleased] + +### Added + +- Added `filters` param to the `Filter` type (#6010) diff --git a/packages/web3-types/src/eth_types.ts b/packages/web3-types/src/eth_types.ts index 1389fdaa720..3c6f209aeca 100644 --- a/packages/web3-types/src/eth_types.ts +++ b/packages/web3-types/src/eth_types.ts @@ -217,6 +217,10 @@ export interface SyncOutput { readonly pulledStates?: Numbers; } +export type Receipt = Record; + +type FilterOption = Record; + // https://github.com/ethereum/execution-apis/blob/main/src/schemas/filter.json#L28 export interface Filter { readonly fromBlock?: BlockNumberOrTag; @@ -226,6 +230,7 @@ export interface Filter { // Using "null" type intentionally to match specifications // eslint-disable-next-line @typescript-eslint/ban-types readonly topics?: (null | Topic | Topic[])[]; + readonly filter?: FilterOption; } export interface AccessListEntry {