From 7d451fc98251a971b5db1d1c4c7d9f95f07f92f4 Mon Sep 17 00:00:00 2001 From: chmanie Date: Mon, 18 Apr 2022 14:50:14 +0200 Subject: [PATCH 1/2] Add first ColonyEvents including example --- .eslintrc | 2 + examples/browser/src/basic.ts | 2 +- examples/browser/src/events.ts | 61 +++++++++++ examples/browser/web/events.html | 13 +++ examples/browser/web/index.html | 3 + package-lock.json | 14 +-- package.json | 4 +- src/Colony.ts | 2 +- src/ColonyEvents.ts | 177 +++++++++++++++++++++++++++++++ src/ColonyNetwork.ts | 4 +- src/index.ts | 5 +- src/utils.ts | 39 +++++++ tsconfig.json | 2 +- 13 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 examples/browser/src/events.ts create mode 100644 examples/browser/web/events.html create mode 100644 src/ColonyEvents.ts diff --git a/.eslintrc b/.eslintrc index f3894d70..3bc082b1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,6 +14,8 @@ "rules": { "no-unused-vars": "off", "no-redeclare": "off", + "no-use-before-define": "off", + "no-dupe-class-members": "off", "@typescript-eslint/no-unused-vars": "error", "no-shadow": "off", "@typescript-eslint/no-shadow": ["error"], diff --git a/examples/browser/src/basic.ts b/examples/browser/src/basic.ts index 813da998..944a75b4 100644 --- a/examples/browser/src/basic.ts +++ b/examples/browser/src/basic.ts @@ -1,6 +1,6 @@ import { providers, utils } from 'ethers'; -import { ColonyNetwork, Tokens } from '../../../dist/esm'; +import { ColonyNetwork, Tokens } from '../../../src'; const { formatEther, isAddress } = utils; diff --git a/examples/browser/src/events.ts b/examples/browser/src/events.ts new file mode 100644 index 00000000..6f687b5e --- /dev/null +++ b/examples/browser/src/events.ts @@ -0,0 +1,61 @@ +import { providers, utils } from 'ethers'; + +import { ColonyEvents } from '../../../src'; +import type { ColonyEvent } from '../../../src'; + +const provider = new providers.JsonRpcProvider('https://xdai.colony.io/rpc2/'); +const { isAddress } = utils; + +// This event listener will only list for the `DomainAdded` event in the Colony of the user's choice. Run this and then create a Team in that Colony, to see it being picked up here +const setupEventListener = ( + colonyAddress: string, + callback: (events: ColonyEvent[]) => void, +) => { + const colonyEvents = new ColonyEvents(provider); + + const domainAdded = colonyEvents.createMultiFilter( + colonyEvents.eventSources.Colony, + 'DomainAdded(address,uint256)', + colonyAddress, + ); + + colonyEvents.provider.on('block', async (no) => { + const events = await colonyEvents.getMultiEvents([domainAdded], { + fromBlock: no, + toBlock: no, + }); + if (events.length) callback(events); + }); +}; + +// Just some basic setup to display the UI +const addressInput: HTMLInputElement = document.querySelector('#address'); +const button = document.querySelector('#button'); +const errElm: HTMLParagraphElement = document.querySelector('#error'); +const resultElm: HTMLParagraphElement = document.querySelector('#result'); + +const panik = (err: string) => { + errElm.innerText = err; +}; +const kalm = () => { + errElm.innerText = ''; +}; +const speak = (msg: string) => { + resultElm.innerText = msg; +}; + +button.addEventListener('click', async () => { + kalm(); + const colonyAddress = addressInput.value; + if (!isAddress(colonyAddress)) { + return panik('This is not a valid address'); + } + addressInput.value = ''; + setupEventListener(colonyAddress, (events) => { + speak( + `A domain with id ${events[0].data.domainId} was created on Colony ${events[0].address}`, + ); + }); + speak(`Set up event listener for Colony ${colonyAddress}`); + return null; +}); diff --git a/examples/browser/web/events.html b/examples/browser/web/events.html new file mode 100644 index 00000000..3ae7ac13 --- /dev/null +++ b/examples/browser/web/events.html @@ -0,0 +1,13 @@ + + + +

ColonyJS browser demo - events

+ +

Listen to Colony Events as they unfold...

+ + + +

+

+ + diff --git a/examples/browser/web/index.html b/examples/browser/web/index.html index 48c987ca..6c7f02f3 100644 --- a/examples/browser/web/index.html +++ b/examples/browser/web/index.html @@ -15,6 +15,9 @@

ColonyJS browser demos

  • Advanced example (creating a domain within a Colony)
  • +
  • + Events example (listening to Colony events) +
  • diff --git a/package-lock.json b/package-lock.json index 72d43c04..4925c8cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "GPL-3.0-only", "dependencies": { - "@colony/colony-js": "^5.0.0" + "@colony/colony-js": "^5.0.2" }, "devDependencies": { "@colony/eslint-config-colony": "^9.0.2", @@ -37,9 +37,9 @@ } }, "node_modules/@colony/colony-js": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@colony/colony-js/-/colony-js-5.0.0.tgz", - "integrity": "sha512-AmPCrWMn3zRWzrCXkTbbcylJjUTP6BXUpRFm8wM9DcaFs8RzEb5Wrq3sOkLiNnXmHU6Q0g03aWtppXekm2MqzQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@colony/colony-js/-/colony-js-5.0.2.tgz", + "integrity": "sha512-ILoNtWeMWeMo+tCv34Wr0PDivP1Kxs73v+GYtB3YDLVhxjo2IifXE7CPLqXfRT3eB7oMRiJFg7V07YOC/5XzxQ==", "dependencies": { "cross-fetch": "^3.1.5" }, @@ -3849,9 +3849,9 @@ }, "dependencies": { "@colony/colony-js": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@colony/colony-js/-/colony-js-5.0.0.tgz", - "integrity": "sha512-AmPCrWMn3zRWzrCXkTbbcylJjUTP6BXUpRFm8wM9DcaFs8RzEb5Wrq3sOkLiNnXmHU6Q0g03aWtppXekm2MqzQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@colony/colony-js/-/colony-js-5.0.2.tgz", + "integrity": "sha512-ILoNtWeMWeMo+tCv34Wr0PDivP1Kxs73v+GYtB3YDLVhxjo2IifXE7CPLqXfRT3eB7oMRiJFg7V07YOC/5XzxQ==", "requires": { "cross-fetch": "^3.1.5" } diff --git a/package.json b/package.json index 9170b0d9..da12c52b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "license": "GPL-3.0-only", "dependencies": { - "@colony/colony-js": "^5.0.0" + "@colony/colony-js": "^5.0.2" }, "devDependencies": { "@colony/eslint-config-colony": "^9.0.2", @@ -28,7 +28,7 @@ }, "scripts": { "example:node": "npm run compile-cjs && ts-node src/index.ts", - "example:browser": "npm run compile-esm && esbuild --bundle examples/browser/src/*.ts --servedir=examples/browser/web", + "example:browser": "esbuild --bundle examples/browser/src/*.ts --servedir=examples/browser/web", "build": "npm run clean && npm run compile-cjs && npm run compile-esm && npm run compile-types && npm run build-docs", "build-docs": "typedoc --out docs --excludeInternal src/index.ts", "clean": "rimraf ./dist", diff --git a/src/Colony.ts b/src/Colony.ts index fa789c45..d7e8379d 100644 --- a/src/Colony.ts +++ b/src/Colony.ts @@ -15,7 +15,7 @@ import { extractEvent } from './utils'; type SupportedColonyClient = ColonyClientV8; -export class Colony { +export default class Colony { static SupportedVersion: 8 = 8; private colonyClient: SupportedColonyClient; diff --git a/src/ColonyEvents.ts b/src/ColonyEvents.ts new file mode 100644 index 00000000..652832c8 --- /dev/null +++ b/src/ColonyEvents.ts @@ -0,0 +1,177 @@ +import { constants, providers, EventFilter } from 'ethers'; +import { Result } from 'ethers/lib/utils'; +import { + IColonyEvents, + IColonyEventsFactory, + IColonyNetwork, + IColonyNetworkFactory, +} from '@colony/colony-js/extras'; +import type { BlockTag, Filter } from '@ethersproject/abstract-provider'; + +import { addressesAreEqual, getLogs, nonNullable } from './utils'; + +type ValueOf = T[keyof T]; + +interface EventSources { + Colony: IColonyEvents; + ColonyNetwork: IColonyNetwork; +} + +type EventSource = ValueOf; + +export interface ColonyFilter extends Filter { + eventSource: keyof EventSources; + eventName: string; +} + +// TODO: consider allowing an address array +/** ColonyFilter with support for multi-events + * For the multi-event compatible filters the following assumptions prevail: + * - `address` is a mandatory field + * - it can only take a single `topic` + * - `fromBlock` and `toBlock` are not available + */ +export interface ColonyMultiFilter + extends Omit { + address: string; + topic: string; +} + +export interface ColonyEvent extends ColonyFilter { + data: Result; +} + +export default class ColonyEvents { + eventSources: EventSources; + + provider: providers.JsonRpcProvider; + + constructor(provider: providers.JsonRpcProvider) { + this.provider = provider; + this.eventSources = { + Colony: IColonyEventsFactory.connect(constants.AddressZero, provider), + ColonyNetwork: IColonyNetworkFactory.connect( + constants.AddressZero, + provider, + ), + }; + } + + private static extractSingleTopic(filter?: Filter) { + if (!filter || !filter.topics) return null; + const topic = filter.topics; + if (typeof topic[0] == 'string') return topic[0]; + if (Array.isArray(topic[0]) && typeof topic[0][0] == 'string') { + return topic[0][0]; + } + return null; + } + + private getEventSourceName(contract: EventSource) { + // Find contract for filter in eventSources to store the name alongside it + const eventSource = Object.entries(this.eventSources).find( + ([, c]) => c === contract, + ); + return eventSource && (eventSource[0] as keyof EventSources); + } + + /** Get all events for the defined filter list and a certain block number. + * All the filters are connected by a logical OR, i.e. it will find ALL given events for ALL the given contract addresses + * This is handy when you want to listen to a fixed set of events for a lot of different contracts + * @remarks: `fromBlock` and `toBlock` properties of the indivdual filters will be ignored + */ + async getMultiEvents( + filters: ColonyMultiFilter[], + options: { fromBlock?: BlockTag; toBlock?: BlockTag } = {}, + ): Promise { + // Unique list of addresses + const addresses = Array.from( + new Set(filters.map(({ address }) => address)), + ); + // Unique list of topics + const topics = Array.from(new Set(filters.map(({ topic }) => topic))); + const logs = await getLogs( + { + address: addresses, + fromBlock: options.fromBlock, + toBlock: options.toBlock, + topics: [topics], + }, + this.provider, + ); + return logs + .map((log) => { + const filter = filters.find( + ({ topic, address }) => + log.topics.includes(topic) && + addressesAreEqual(address, log.address), + ); + if (!filter) return null; + const { eventSource, topic, eventName, address } = filter; + const data = this.eventSources[eventSource].interface.decodeEventLog( + eventName, + log.data, + log.topics, + ); + return { + address, + eventSource, + topic, + eventName, + data, + }; + }) + .filter(nonNullable); + } + + createFilter< + T extends EventSource & { + filters: { [P in N]: (...args: never) => EventFilter }; + }, + N extends keyof T['filters'], + >( + contract: T, + eventName: N, + address?: string, + params?: Parameters, + options: { fromBlock?: BlockTag; toBlock?: BlockTag } = {}, + ): ColonyFilter { + // Create standard filter + const filter = contract.filters[eventName].apply(params); + const eventSource = this.getEventSourceName(contract); + if (!eventSource) { + throw new Error(`Could not find event contract for filter`); + } + return { + eventSource, + eventName: eventName as string, + topics: filter.topics, + address, + fromBlock: options.fromBlock, + toBlock: options.toBlock, + }; + } + + createMultiFilter< + T extends EventSource & { + filters: { [P in N]: (...args: never) => EventFilter }; + }, + N extends keyof T['filters'], + >( + contract: T, + eventName: N, + address: string, + params?: Parameters, + ): ColonyMultiFilter { + const filter = this.createFilter(contract, eventName, address, params); + // Just use the first topic for now. Let's see how far we get with this. They will be connected with ORs + const topic = ColonyEvents.extractSingleTopic(filter); + if (!topic) { + throw new Error(`Filter does not have any topics: ${eventName}`); + } + delete filter.topics; + const colonyFilter = filter as ColonyMultiFilter; + colonyFilter.topic = topic; + return colonyFilter; + } +} diff --git a/src/ColonyNetwork.ts b/src/ColonyNetwork.ts index 58a223b5..cb0635f9 100644 --- a/src/ColonyNetwork.ts +++ b/src/ColonyNetwork.ts @@ -5,9 +5,9 @@ import { SignerOrProvider, } from '@colony/colony-js'; -import { Colony } from './Colony'; +import Colony from './Colony'; -export class ColonyNetwork { +export default class ColonyNetwork { private networkClient: ColonyNetworkClient; private signerOrProvider: SignerOrProvider; diff --git a/src/index.ts b/src/index.ts index fe36c00c..1825146c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ export { Tokens } from '@colony/colony-js'; -export { ColonyNetwork } from './ColonyNetwork'; +export { default as ColonyNetwork } from './ColonyNetwork'; +export { default as ColonyEvents } from './ColonyEvents'; + +export type { ColonyEvent } from './ColonyEvents'; diff --git a/src/utils.ts b/src/utils.ts index b26e80a5..7173a1bd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,12 @@ import { ContractReceipt } from 'ethers'; +import type { + Filter, + FilterByBlockHash, + Log, +} from '@ethersproject/abstract-provider'; +import type { JsonRpcProvider } from '@ethersproject/providers'; +/** Extract event args from a contract receipt */ export const extractEvent = ( eventName: string, receipt: ContractReceipt, @@ -12,3 +19,35 @@ export const extractEvent = ( } return undefined; }; + +/** Ethers 6 supports mulitple addresses in a filter. Until then we have this */ +export interface Ethers6Filter extends Omit { + address: string | string[]; +} + +/** Ethers 6 supports mulitple addresses in a filter. Until then we have this */ +export interface Ethers6FilterByBlockHash + extends Omit { + address: string | string[]; +} + +/** Version of `getLogs` that also supports filtering for multiple addresses */ +export const getLogs = async ( + filter: + | Ethers6Filter + | Ethers6FilterByBlockHash + | Promise, + provider: JsonRpcProvider, +): Promise => { + const usedFilter = await filter; + return provider.send('eth_getLogs', [usedFilter]); +}; + +/** @internal */ +export const addressesAreEqual = (a: string, b: string) => + a.toLowerCase() === b.toLowerCase(); + +/** @internal */ +export const nonNullable = (value: T): value is NonNullable => { + return value !== null && value !== undefined; +}; diff --git a/tsconfig.json b/tsconfig.json index 18fdd452..9b6fc1b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "resolveJsonModule": true, "esModuleInterop": true, "strict": true, - "target": "es6", + "target": "es2015", "types": ["node"], "rootDir": "./src" }, From b1c191fcde1e53ff3a1ff2a9ee66ba3d1f46b1e3 Mon Sep 17 00:00:00 2001 From: chmanie Date: Tue, 19 Apr 2022 15:25:12 +0200 Subject: [PATCH 2/2] Debounce event listener in events example --- examples/browser/src/events.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/browser/src/events.ts b/examples/browser/src/events.ts index 6f687b5e..f7acd495 100644 --- a/examples/browser/src/events.ts +++ b/examples/browser/src/events.ts @@ -19,12 +19,19 @@ const setupEventListener = ( colonyAddress, ); + let i = 0; + colonyEvents.provider.on('block', async (no) => { - const events = await colonyEvents.getMultiEvents([domainAdded], { - fromBlock: no, - toBlock: no, - }); - if (events.length) callback(events); + i += 1; + // Only get events every 5 blocks to debounce this a little bit + if (i === 4) { + const events = await colonyEvents.getMultiEvents([domainAdded], { + fromBlock: no - i, + toBlock: no, + }); + if (events.length) callback(events); + i = 0; + } }); };