From 6e71509c3b0de7dbf6cf0466fab9432081b7f979 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 4 May 2020 19:14:51 -0600 Subject: [PATCH] Emit events for chain notifications (re-posted after revert) (#57) * Speccing out overall module structure for events. * Attempt to fetch and parse blocks from Substrate. * Make interface testable and add first unit test. * Adding counter to subscribe test. * Moving event handlers into shared. * Move processor types to utils and add test. * Fix node types import. Add standalone event subscriber. * Wire up chain events to the database. * Subscription modal and menu UI for chain events. * Use association between Notification and ChainEvent. * Augment ChainEventType with metadata. * Update raw_name of ChainEventType to 'section.method'. * Update header event display format. * Add hacky redraw * Add polling if chain or server goes offline. * Always attempt default offline range algorithm. * Fix polling bugs, filter pruned blocks. * Add tentative migration. * Stubbing out filter system. * Initial implementation of filters. * Fix bugs to permit full stack notifications. * Fix processor unit test. * Add kinds for substrate event types. * Fix function call in header. * Remove balance formatter requiring chain from header. * Add CWEvent abstraction layer and affectedAddresses to event. * Fix affected address notification filter. * Add tentative URLs to some chain events. * Write poller unit tests. * Add events README and update types. * Increase test coverage. * Revert forgotten local change to package.json * Add new chain event types. * Add event handler server tests. * Add test-events command and remove db logging. * Switch CircleCI tests to API only. * Add exclude addresses to events. Remove author reliance in emitSubscription. * Add event testing to circleci config. * Add titler, remove modal, and update subscriptions page. * Update readme for titler and subscription page. * Add balance format, debug validator reward inconsistency. * Fix down migration. * Update for PR requests. * Add version to events. * Fix subscription page dropdown ordering. * Fix merge issues. * Fix dropdown sort for subscriptions, redux. * Move version from event_data to chain_event model. * Stub out new chain events. * Implement new events fully. * Fix unit tests and related bugs. * Add processor fail tests. * Update migration for new types. * Add stubs for enricher tests. * Move usage of chain version to type_parser and off db. * Add TreasuryRewardMinted event. * Add TreasuryRewardMintedV2 event. * Add full enricher tests. * Remove unused version from chain event creation. * Fix errors from Kusama chain upgrade. * Swap dispatch queue to new derive. * Add subscribe all button for chain events. * Fixes redirect on subscription page * Fixes for creation and tx fees * Fixes differences in client/server edgeware url setups * Adds flag for event logging * Add extrinsic to edgeware event logic + candidacy event. * Swap candidacy notification link to council page. * Add chain name to notification heading. * Add candidacy event to migration. Co-authored-by: Jake Naviasky Co-authored-by: Raymond Zhong --- .circleci/config.yml | 3 + .eslintrc.json | 7 +- .../controllers/chain/substrate/account.ts | 13 +- client/scripts/models/ChainEvent.ts | 27 + client/scripts/models/ChainEventType.ts | 19 + client/scripts/models/Notification.ts | 16 +- client/scripts/models/index.ts | 2 + .../components/settings/send_edg_well.ts | 18 +- .../components/sidebar/notification_row.ts | 57 +- client/scripts/views/pages/subscriptions.ts | 124 ++++ client/styles/pages/subscriptions.scss | 11 + eventSubscriber.ts | 14 + package.json | 4 +- server-test.ts | 21 + server.ts | 3 + server/database.ts | 5 +- server/eventHandlers/edgeware.ts | 60 ++ .../20200415152936-add-chain-events.js | 177 +++++ .../20200417182339-add-notif-category.js | 20 +- server/models/chain_event.ts | 26 + server/models/chain_event_type.ts | 29 + server/models/chain_node.ts | 2 +- server/models/notification.ts | 2 + server/models/subscription.ts | 73 +- server/routes/createComment.ts | 5 +- server/routes/createCommunity.ts | 1 + server/routes/createReaction.ts | 1 + server/routes/createThread.ts | 4 +- server/routes/editComment.ts | 1 + server/routes/editThread.ts | 1 + server/routes/viewNotifications.ts | 10 + server/scripts/resetServer.ts | 23 +- server/scripts/setupChainEventListeners.ts | 53 ++ shared/adapters/currency.ts | 2 +- shared/events/README.md | 29 + shared/events/edgeware/filters/enricher.ts | 467 +++++++++++++ shared/events/edgeware/filters/labeler.ts | 386 +++++++++++ shared/events/edgeware/filters/titler.ts | 262 ++++++++ shared/events/edgeware/filters/type_parser.ts | 89 +++ shared/events/edgeware/index.ts | 133 ++++ shared/events/edgeware/poller.ts | 61 ++ shared/events/edgeware/processor.ts | 57 ++ shared/events/edgeware/subscriber.ts | 54 ++ shared/events/edgeware/types.ts | 384 +++++++++++ shared/events/interfaces.ts | 86 +++ shared/types.ts | 1 + test/mocha.opts | 3 +- test/unit/events/edgeware/enricher.spec.ts | 629 ++++++++++++++++++ .../unit/events/edgeware/eventHandler.spec.ts | 230 +++++++ test/unit/events/edgeware/poller.spec.ts | 144 ++++ test/unit/events/edgeware/processor.spec.ts | 264 ++++++++ test/unit/events/edgeware/subscriber.spec.ts | 126 ++++ test/unit/events/edgeware/testUtil.ts | 74 +++ 53 files changed, 4262 insertions(+), 51 deletions(-) create mode 100644 client/scripts/models/ChainEvent.ts create mode 100644 client/scripts/models/ChainEventType.ts create mode 100644 eventSubscriber.ts create mode 100644 server/eventHandlers/edgeware.ts create mode 100644 server/migrations/20200415152936-add-chain-events.js create mode 100644 server/models/chain_event.ts create mode 100644 server/models/chain_event_type.ts create mode 100644 server/scripts/setupChainEventListeners.ts create mode 100644 shared/events/README.md create mode 100644 shared/events/edgeware/filters/enricher.ts create mode 100644 shared/events/edgeware/filters/labeler.ts create mode 100644 shared/events/edgeware/filters/titler.ts create mode 100644 shared/events/edgeware/filters/type_parser.ts create mode 100644 shared/events/edgeware/index.ts create mode 100644 shared/events/edgeware/poller.ts create mode 100644 shared/events/edgeware/processor.ts create mode 100644 shared/events/edgeware/subscriber.ts create mode 100644 shared/events/edgeware/types.ts create mode 100644 shared/events/interfaces.ts create mode 100644 test/unit/events/edgeware/enricher.spec.ts create mode 100644 test/unit/events/edgeware/eventHandler.spec.ts create mode 100644 test/unit/events/edgeware/poller.spec.ts create mode 100644 test/unit/events/edgeware/processor.spec.ts create mode 100644 test/unit/events/edgeware/subscriber.spec.ts create mode 100644 test/unit/events/edgeware/testUtil.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 929a7b954d7..1ec6fa88941 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,5 +33,8 @@ jobs: - run: name: run api tests command: yarn test-api + - run: + name: run event tests + command: yarn test-events - store_artifacts: path: coverage diff --git a/.eslintrc.json b/.eslintrc.json index c1da0b31b39..f0a62d7d90e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -47,8 +47,13 @@ "lines-between-class-members": 0, "max-classes-per-file": ["error", 5], "max-len": ["error", {"code": 120, "tabWidth": 2}], - "dot-notation": 0, "no-constant-condition": ["error", {"checkLoops": false}], "no-restricted-syntax": 0 + "no-trailing-spaces": ["error"], + "no-useless-constructor": 0, + "no-empty-function": 0, + "import/prefer-default-export": 0, + "dot-notation": 0, + "no-lonely-if": 0 } } diff --git a/client/scripts/controllers/chain/substrate/account.ts b/client/scripts/controllers/chain/substrate/account.ts index b513fc3bd17..357316f03ed 100644 --- a/client/scripts/controllers/chain/substrate/account.ts +++ b/client/scripts/controllers/chain/substrate/account.ts @@ -387,9 +387,16 @@ export class SubstrateAccount extends Account { public get balanceTransferFee(): Observable { if (this.chainClass === ChainClass.Edgeware) { // grab const tx fee on edgeware - return this._Chain.api.pipe( - map((api: ApiRx) => this._Chain.coins(api.consts.balances.transferFee as Balance)) - ); + return from(this._Chain.api.pipe(map((api: ApiRx) => (api.consts.balances.transferFee as Balance))) + .toPromise() + .then((txFee) => { + if (txFee) { + return Promise.resolve(this._Chain.coins(txFee)); + } else { + const dummyTxFunc = (api: ApiRx) => api.tx.balances.transfer(this.address, '0'); + return this._Chain.computeFees(this.address, dummyTxFunc); + } + })); } else { // compute fee on Kusama const dummyTxFunc = (api: ApiRx) => api.tx.balances.transfer(this.address, '0'); diff --git a/client/scripts/models/ChainEvent.ts b/client/scripts/models/ChainEvent.ts new file mode 100644 index 00000000000..04b30a14d40 --- /dev/null +++ b/client/scripts/models/ChainEvent.ts @@ -0,0 +1,27 @@ +import { IChainEventData } from 'events/interfaces'; +import ChainEventType from './ChainEventType'; + +class ChainEvent { + public readonly id: number; + public readonly blockNumber: number; + public readonly data: IChainEventData; + public readonly type: ChainEventType; + + constructor(id, blockNumber, data, type) { + this.id = id; + this.blockNumber = blockNumber; + this.data = data; + this.type = type; + } + + public static fromJSON(json) { + return new ChainEvent( + json.id, + json.block_number, + json.event_data, + ChainEventType.fromJSON(json.ChainEventType), + ); + } +} + +export default ChainEvent; diff --git a/client/scripts/models/ChainEventType.ts b/client/scripts/models/ChainEventType.ts new file mode 100644 index 00000000000..b490059d7b0 --- /dev/null +++ b/client/scripts/models/ChainEventType.ts @@ -0,0 +1,19 @@ +import { SubstrateEventKind } from 'shared/events/edgeware/types'; + +class ChainEventType { + public readonly id: string; + public readonly chain: string; + public readonly eventName: SubstrateEventKind; + + constructor(id, chain, eventName) { + this.id = id; + this.chain = chain; + this.eventName = eventName; + } + + public static fromJSON(json) { + return new ChainEventType(json.id, json.chain, json.event_name); + } +} + +export default ChainEventType; diff --git a/client/scripts/models/Notification.ts b/client/scripts/models/Notification.ts index 2d873a66556..52fae12aeab 100644 --- a/client/scripts/models/Notification.ts +++ b/client/scripts/models/Notification.ts @@ -1,24 +1,28 @@ import moment from 'moment-twitter'; import NotificationSubscription from './NotificationSubscription'; +import ChainEvent from './ChainEvent'; class Notification { public readonly id: number; public readonly data: string; public readonly createdAt: moment.Moment; public readonly subscription: NotificationSubscription; + public readonly chainEvent?: ChainEvent; private _isRead: boolean; public get isRead(): boolean { return this._isRead; } - constructor(id, data, isRead, createdAt, subscription) { + constructor(id, data, isRead, createdAt, subscription, chainEvent?) { this.id = id; this.data = data; this._isRead = isRead; this.createdAt = moment(createdAt); this.subscription = subscription; + this.chainEvent = chainEvent; } + public markRead() { if (this._isRead) { throw new Error('notification already read!'); @@ -26,8 +30,16 @@ class Notification { this._isRead = true; } } + public static fromJSON(json, subscription: NotificationSubscription) { - return new Notification(json.id, json.notification_data, json.is_read, json.created_at, subscription); + return new Notification( + json.id, + json.notification_data, + json.is_read, + json.created_at, + subscription, + json.ChainEvent ? ChainEvent.fromJSON(json.ChainEvent) : undefined, + ); } } diff --git a/client/scripts/models/index.ts b/client/scripts/models/index.ts index 42d35d0dd60..228b74b6445 100644 --- a/client/scripts/models/index.ts +++ b/client/scripts/models/index.ts @@ -26,6 +26,8 @@ export { default as StorageModule } from './StorageModule'; export { default as ChainObject } from './ChainObject'; export { default as ChainObjectQuery } from './ChainObjectQuery'; export { default as ChainObjectVersion } from './ChainObjectVersion'; +export { default as ChainEventType } from './ChainEventType'; +export { default as ChainEvent } from './ChainEvent'; export { DepositVote, BinaryVote } from './votes'; diff --git a/client/scripts/views/components/settings/send_edg_well.ts b/client/scripts/views/components/settings/send_edg_well.ts index 1f50255d8ad..623c7cb748a 100644 --- a/client/scripts/views/components/settings/send_edg_well.ts +++ b/client/scripts/views/components/settings/send_edg_well.ts @@ -46,16 +46,20 @@ const getBalanceTransferChecks = ( `Transfer fee: ${formatCoin(txFee)}` ]); } - if (canTransfer && recipientBalance.eqn(0) && (app.chain as Substrate).chain.creationfee.gtn(0)) { - checks.push([ - featherIcon('info', 14, 2, '#444'), - `Account creation fee: ${formatCoin((app.chain as Substrate).chain.creationfee)}` - ]); + + const creationFee = (app.chain as Substrate).chain.creationfee; + if (canTransfer && recipientBalance.eqn(0) && creationFee) { + if (creationFee.gtn(0)) { + checks.push([ + featherIcon('info', 14, 2, '#444'), + `Account creation fee: ${formatCoin((app.chain as Substrate).chain.creationfee)}` + ]); + } } + const resultingBalance = app.chain.chain.coins(recipientBalance.add(recipientBalance.gtn(0) ? amount.sub(txFee) - : amount.sub(txFee) - .sub((app.chain as Substrate).chain.creationfee))); + : (creationFee) ? amount.sub(txFee).sub(creationFee) : amount.sub(txFee))); if (recipientBalance.eqn(0) && resultingBalance.lt((app.chain as Substrate).chain.existentialdeposit)) { checks.push([ featherIcon('slash', 14, 2, '#444'), diff --git a/client/scripts/views/components/sidebar/notification_row.ts b/client/scripts/views/components/sidebar/notification_row.ts index 13c237612c4..527593ad869 100644 --- a/client/scripts/views/components/sidebar/notification_row.ts +++ b/client/scripts/views/components/sidebar/notification_row.ts @@ -10,6 +10,7 @@ import { NotificationCategories } from 'types'; import { ProposalType } from 'identifiers'; import { Notification } from 'models'; import { IPostNotificationData, ICommunityNotificationData } from 'shared/types'; +import labelEdgewareEvent from '../../../../../shared/events/edgeware/filters/labeler'; import QuillFormattedText, { sliceQuill } from 'views/components/quill_formatted_text'; import MarkdownFormattedText from 'views/components/markdown_formatted_text'; @@ -128,14 +129,54 @@ const HeaderNotificationRow: m.Component = { pageJump } = getNotificationFields(category, JSON.parse(notification.data)); - return getHeaderNotificationRow( - author, - createdAt, - notificationHeader, - notificationBody, - path, - pageJump - ); + if (category === NotificationCategories.ChainEvent) { + if (!notification.chainEvent) { + throw new Error('chain event notification does not have expected data'); + } + // TODO: use different labelers depending on chain + const chainId = notification.chainEvent.type.chain; + const chainName = app.config.chains.getById(chainId).name; + const label = labelEdgewareEvent( + notification.chainEvent.blockNumber, + chainId, + notification.chainEvent.data, + ); + return m('li.HeaderNotificationRow', { + class: notification.isRead ? '' : 'active', + onclick: async () => { + const notificationArray: Notification[] = []; + notificationArray.push(notification); + app.login.notifications.markAsRead(notificationArray).then(() => m.redraw()); + if (!label.linkUrl) return; + await m.route.set(label.linkUrl); + m.redraw.sync(); + }, + }, [ + m('.comment-body', [ + m('.comment-body-top', `${label.heading} on ${chainName}`), + m('.comment-body-bottom', `Block ${notification.chainEvent.blockNumber}`), + m('.comment-body-excerpt', label.label), + ]), + ]); + } else { + const { + author, + createdAt, + notificationHeader, + notificationBody, + path, + pageJump + } = getNotificationFields(category, JSON.parse(notification.data)); + + return getHeaderNotificationRow( + author, + createdAt, + notificationHeader, + notificationBody, + path, + pageJump + ); + } // else if (category === NotificationCategories.NewCommunity) { // //const { created_at, proposal_id } = JSON.parse(notification.data); diff --git a/client/scripts/views/pages/subscriptions.ts b/client/scripts/views/pages/subscriptions.ts index 23b9d5f622a..316a0342396 100644 --- a/client/scripts/views/pages/subscriptions.ts +++ b/client/scripts/views/pages/subscriptions.ts @@ -6,7 +6,12 @@ import $ from 'jquery'; import { NotificationSubscription, ChainInfo, CommunityInfo } from 'models'; import app from 'state'; import { NotificationCategories } from 'types'; +import { SubstrateEventKinds } from 'events/edgeware/types'; +import EdgewareTitlerFunc from 'events/edgeware/filters/titler'; +import { IChainEventKind, EventSupportingChains, TitlerFilter } from 'events/interfaces'; +import ListingPage from './_listing_page'; import Tabs from '../components/widgets/tabs'; +import { DropdownFormField } from '../components/forms'; const NotificationButtons: m.Component = { oninit: (vnode) => { @@ -306,6 +311,122 @@ const CommunitySubscriptions: m.Component<{}, ICommunitySubscriptionsState> = { } }; +interface IEventSubscriptionRowAttrs { + chain: string; + kind: IChainEventKind; + titler: TitlerFilter; +} + +const EventSubscriptionRow: m.Component = { + view: (vnode) => { + const { chain, kind } = vnode.attrs; + const { title, description } = vnode.attrs.titler(kind); + const objectId = `${chain}-${kind}`; + const subscription = app.loginStatusLoaded && app.login.notifications.subscriptions + .find((sub) => sub.category === NotificationCategories.ChainEvent + && sub.objectId === objectId); + return m('.EventSubscriptionRow', [ + m('h3', `${title}`), + app.loginStatusLoaded && m('button.activeSubscriptionButton', { + class: subscription && subscription.isActive ? 'formular-button-primary' : '', + onclick: async (e) => { + e.preventDefault(); + if (subscription && subscription.isActive) { + await app.login.notifications.disableSubscriptions([ subscription ]); + } else { + await app.login.notifications.subscribe(NotificationCategories.ChainEvent, objectId); + } + setTimeout(() => { m.redraw(); }, 0); + } + }, subscription && subscription.isActive + ? [ m('span.icon-bell'), ' Notifications on' ] + : [ m('span.icon-bell-off'), ' Notifications off' ]), + m('span', description), + ]); + } +}; + +interface IEventSubscriptionState { + chain: string; + eventKinds: IChainEventKind[]; + allSupportedChains: string[]; + isSubscribedAll: boolean; +} + +const EventSubscriptions: m.Component<{}, IEventSubscriptionState> = { + oninit: (vnode) => { + vnode.state.chain = EventSupportingChains.sort()[0]; + vnode.state.eventKinds = SubstrateEventKinds; + vnode.state.allSupportedChains = EventSupportingChains.sort(); + vnode.state.isSubscribedAll = false; + }, + view: (vnode) => { + let titler; + if (vnode.state.chain === 'edgeware' || vnode.state.chain === 'edgeware-local') { + titler = EdgewareTitlerFunc; + vnode.state.eventKinds = SubstrateEventKinds; + } else { + titler = null; + vnode.state.eventKinds = []; + } + + const allSubscriptions = app.login.notifications.subscriptions + .filter((sub) => sub.category === NotificationCategories.ChainEvent + && vnode.state.eventKinds.find((kind) => sub.objectId === `${vnode.state.chain}-${kind}`)); + const allActiveSubscriptions = allSubscriptions.filter((sub) => sub.isActive); + vnode.state.isSubscribedAll = allActiveSubscriptions.length === vnode.state.eventKinds.length; + + const supportedChains = app.loginStatusLoaded + ? app.config.chains.getAll() + .filter((c) => vnode.state.allSupportedChains.includes(c.id)) + .sort((a, b) => a.id.localeCompare(b.id)) + : []; + return m('.EventSubscriptions', [ + m('h1', 'On-Chain Events'), + supportedChains.length > 0 && m(DropdownFormField, { + name: 'chain', + choices: supportedChains.map((t) => ({ name: t.id, label: t.name, value: t.id })), + callback: (result) => { + vnode.state.chain = result; + setTimeout(() => { m.redraw(); }, 0); + }, + }), + m('h2', 'Subscribe to New Events:'), + m('.EventSubscriptionRow', [ + m('h3', 'Subscribe All'), + app.loginStatusLoaded && m('button.activeSubscriptionButton', { + class: vnode.state.isSubscribedAll ? 'formular-button-primary' : '', + onclick: async (e) => { + e.preventDefault(); + if (vnode.state.isSubscribedAll) { + await app.login.notifications.disableSubscriptions(allActiveSubscriptions); + } else { + await Promise.all( + vnode.state.eventKinds.map((kind) => { + return app.login.notifications.subscribe( + NotificationCategories.ChainEvent, + `${vnode.state.chain}-${kind.toString()}` + ); + }) + ); + } + setTimeout(() => { m.redraw(); }, 0); + } + }, vnode.state.isSubscribedAll + ? [ m('span.icon-bell'), ' Notifications on' ] + : [ m('span.icon-bell-off'), ' Notifications off' ]), + m('span', 'Subscribe to all notifications on chain.'), + ]), + supportedChains.length > 0 && vnode.state.eventKinds.length > 0 && titler + ? vnode.state.eventKinds.map((kind) => m( + EventSubscriptionRow, + { chain: vnode.state.chain, kind, titler }, + )) + : m('No events available on this chain.') + ]); + } +}; + const SubscriptionsPage: m.Component = { view: () => { return m('.SubscriptionsPage', [ @@ -319,6 +440,9 @@ const SubscriptionsPage: m.Component = { }, { name: 'Community Subscriptions', content: m(CommunitySubscriptions), + }, { + name: 'Event Subscriptions', + content: m(EventSubscriptions), }, { name: 'Notifications', content: m(NotificationButtons), diff --git a/client/styles/pages/subscriptions.scss b/client/styles/pages/subscriptions.scss index f81afd148cd..070d03a1569 100644 --- a/client/styles/pages/subscriptions.scss +++ b/client/styles/pages/subscriptions.scss @@ -38,4 +38,15 @@ margin: auto 20px; } } + + .EventSubscriptionRow { + .activeSubscriptionButton { + height: 34px; + margin: auto 20px; + width: 200px; + } + } + .DropdownFormField { + width: 50%; + } } \ No newline at end of file diff --git a/eventSubscriber.ts b/eventSubscriber.ts new file mode 100644 index 00000000000..29cc8c105b1 --- /dev/null +++ b/eventSubscriber.ts @@ -0,0 +1,14 @@ +import subscribeEdgewareEvents from './shared/events/edgeware/index'; +import { IEventHandler, CWEvent } from './shared/events/interfaces'; + +const url = process.env.NODE_URL || undefined; +const DEV = process.env.NODE_ENV !== 'production'; +class StandaloneSubstrateEventHandler extends IEventHandler { + public async handle(event: CWEvent) { + // just prints the event + if (DEV) console.log(`Received event: ${JSON.stringify(event, null, 2)}`); + } +} + +const skipCatchup = false; +subscribeEdgewareEvents(url, new StandaloneSubstrateEventHandler(), skipCatchup); diff --git a/package.json b/package.json index 13e3a39942f..e2b16b43c55 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "lint": "./scripts/lint-new-work.sh", "lint-all": "eslint client/\\**/*.ts server/\\**/*.ts", "test-client": "webpack-dev-server --config webpack/webpack.config.test.js", - "integration-test": "nyc ts-mocha --project tsconfig.node.json ./test/integration/**/*.spec.ts", + "test-events": "nyc ts-mocha --project test/tsconfig.node.json ./test/unit/events/**/*.spec.ts", + "integration-test": "nyc ts-mocha --project test/tsconfig.node.json ./test/integration/**/*.spec.ts", "test": "nyc ts-mocha --project test/tsconfig.node.json ./test/unit/**/*.spec.ts ./test/integration/**/*.spec.ts --exit", "test-api": "NODE_ENV=test nyc ts-mocha --project test/tsconfig.node.json ./test/unit/api/*.spec.ts --exit", "start": "nodemon --delay 0.25 --watch 'server/**/*.ts' --ignore 'server/**/*.spec.ts' --exec 'ts-node --project tsconfig.node.json' server.ts", "start-prerender": "ts-node --project tsconfig.node.json server/scripts/runPrerenderService.ts", + "start-events": "ts-node -P ./tsconfig.node.json -T ./eventSubscriber.ts", "migrate-server": "heroku run npx sequelize db:migrate --debug", "reset-server": "NO_CLIENT=true RESET_DB=true ts-node --project tsconfig.node.json server.ts", "update-events": "UPDATE_EVENTS=true ts-node --project tsconfig.node.json server.ts", diff --git a/server-test.ts b/server-test.ts index 6e99079635b..55f7d8ba67b 100644 --- a/server-test.ts +++ b/server-test.ts @@ -18,6 +18,7 @@ import setupWebsocketServer from './server/socket'; import { NotificationCategories } from './shared/types'; import ChainObjectFetcher from './server/util/chainObjectFetcher'; import ViewCountCache from './server/util/viewCountCache'; +import { SubstrateEventKinds } from './shared/events/edgeware/types'; require('express-async-errors'); @@ -148,6 +149,10 @@ const resetServer = (debug=false): Promise => { name: NotificationCategories.NewMention, description: 'someone @ mentions a user', }); + await models['NotificationCategory'].create({ + name: NotificationCategories.ChainEvent, + description: 'a chain event occurs', + }); await models['NotificationCategory'].create({ name: NotificationCategories.NewReaction, description: 'someone reacts to a post', @@ -175,6 +180,22 @@ const resetServer = (debug=false): Promise => { [ 'wss://mainnet.infura.io/ws', 'ethereum' ], ]; await Promise.all(nodes.map(([ url, chain, address ]) => (models['ChainNode'].create({ chain, url, address })))); + + // initialize chain event types + const initChainEventTypes = (chain) => { + return Promise.all( + SubstrateEventKinds.map((event_name) => { + return models['ChainEventType'].create({ + id: `${chain}-${event_name}`, + chain, + event_name, + }); + }) + ); + }; + + await initChainEventTypes('edgeware'); + if (debug) console.log('Database reset!'); resolve(); }); diff --git a/server.ts b/server.ts index f41ef816516..d4709500ea2 100644 --- a/server.ts +++ b/server.ts @@ -32,6 +32,7 @@ import setupErrorHandlers from './server/scripts/setupErrorHandlers'; import setupPrerenderServer from './server/scripts/setupPrerenderService'; import setupAPI from './server/router'; import setupPassport from './server/passport'; +import setupChainEventListeners from './server/scripts/setupChainEventListeners'; import addChainObjectQueries from './server/scripts/addChainObjectQueries'; import ChainObjectFetcher from './server/util/chainObjectFetcher'; import { UserRequest } from './server/types.js'; @@ -50,6 +51,7 @@ const SHOULD_ADD_TEST_QUERIES = process.env.ADD_TEST_QUERIES === 'true'; const SHOULD_UPDATE_EDGEWARE_LOCKDROP_STATS = process.env.UPDATE_EDGEWARE_LOCKDROP_STATS === 'true'; const FETCH_INTERVAL_MS = +process.env.FETCH_INTERVAL_MS || 600000; // default fetch interval is 10min const NO_CLIENT_SERVER = process.env.NO_CLIENT === 'true'; +const SKIP_EVENT_CATCHUP = process.env.SKIP_EVENT_CATCHUP === 'true'; const rollbar = process.env.NODE_ENV === 'production' && new Rollbar({ accessToken: ROLLBAR_SERVER_TOKEN, @@ -199,6 +201,7 @@ if (SHOULD_RESET_DB) { }); }); } else { + setupChainEventListeners(models, wss, SKIP_EVENT_CATCHUP); setupServer(app, wss, sessionParser); if (!NO_ARCHIVE) fetcher.enable(); } diff --git a/server/database.ts b/server/database.ts index 7c2fd9b512d..8a559a9186e 100644 --- a/server/database.ts +++ b/server/database.ts @@ -7,7 +7,10 @@ import { DATABASE_URI } from './config'; const sequelize = new Sequelize(DATABASE_URI, { // disable string operators (https://github.com/sequelize/sequelize/issues/8417) operatorsAliases: false, - logging: (process.env.NODE_ENV === 'test') ? false : () => {}, + logging: (process.env.NODE_ENV === 'test') ? false : (msg) => { }, + dialectOptions: { + requestTimeout: 10000 + }, }); const db = { sequelize, Sequelize }; diff --git a/server/eventHandlers/edgeware.ts b/server/eventHandlers/edgeware.ts new file mode 100644 index 00000000000..2310c26e8e0 --- /dev/null +++ b/server/eventHandlers/edgeware.ts @@ -0,0 +1,60 @@ +/** + * Transforms raw edgeware events into the final form for storage + */ +import { IEventHandler, CWEvent } from '../../shared/events/interfaces'; +import { NotificationCategories } from '../../shared/types'; + +export default class extends IEventHandler { + constructor( + private readonly _models, + private readonly _wss, + private readonly _chain: string, + ) { + super(); + } + + /** + * Handles an event by transforming it as needed. + * @param event the raw event from chain + * @returns the processed event + */ + public async handle(event: CWEvent) { + console.log(`Received event: ${JSON.stringify(event, null, 2)}`); + // locate event type and add event to database + const dbEventType = await this._models.ChainEventType.findOne({ where: { + chain: this._chain, + event_name: event.data.kind.toString(), + } }); + if (!dbEventType) { + console.error(`unknown event type: ${event.data.kind}`); + return; + } else { + console.log(`found chain event type: ${dbEventType.id}`); + } + + // create event in db + const dbEvent = await this._models.ChainEvent.create({ + chain_event_type_id: dbEventType.id, + block_number: event.blockNumber, + event_data: event.data, + }); + + console.log(`created db event: ${dbEvent.id}`); + + // locate subscriptions generate notifications as needed + const dbNotifications = await this._models.Subscription.emitNotifications( + this._models, + NotificationCategories.ChainEvent, + dbEventType.id, + { + created_at: new Date(), + }, + { }, // TODO: add webhook data once specced out + this._wss, + event.excludeAddresses, + event.includeAddresses, + dbEvent.id, + ); + console.log(`Emitted ${dbNotifications.length} notifications.`); + } +} diff --git a/server/migrations/20200415152936-add-chain-events.js b/server/migrations/20200415152936-add-chain-events.js new file mode 100644 index 00000000000..55e4d17f51d --- /dev/null +++ b/server/migrations/20200415152936-add-chain-events.js @@ -0,0 +1,177 @@ +'use strict'; +const SequelizeLib = require('sequelize'); +const Op = SequelizeLib.Op; + +// TODO: if we can use typescript in migrations, we can simply get these +// from the shared/events/edgeware/types file. + +const SubstrateEventKinds = { + Slash: 'slash', + Reward: 'reward', + Bonded: 'bonded', + Unbonded: 'unbonded', + + VoteDelegated: 'vote-delegated', + DemocracyProposed: 'democracy-proposed', + DemocracyTabled: 'democracy-tabled', + DemocracyStarted: 'democracy-started', + DemocracyPassed: 'democracy-passed', + DemocracyNotPassed: 'democracy-not-passed', + DemocracyCancelled: 'democracy-cancelled', + DemocracyExecuted: 'democracy-executed', + + PreimageNoted: 'preimage-noted', + PreimageUsed: 'preimage-used', + PreimageInvalid: 'preimage-invalid', + PreimageMissing: 'preimage-missing', + PreimageReaped: 'preimage-reaped', + + TreasuryProposed: 'treasury-proposed', + TreasuryAwarded: 'treasury-awarded', + TreasuryRejected: 'treasury-rejected', + + ElectionNewTerm: 'election-new-term', + ElectionEmptyTerm: 'election-empty-term', + ElectionCandidacySubmitted: 'election-candidacy-submitted', + ElectionMemberKicked: 'election-member-kicked', + ElectionMemberRenounced: 'election-member-renounced', + + CollectiveProposed: 'collective-proposed', + CollectiveApproved: 'collective-approved', + CollectiveDisapproved: 'collective-disapproved', + CollectiveExecuted: 'collective-executed', + CollectiveMemberExecuted: 'collective-member-executed', + // TODO: do we want to track votes as events, in collective? + + SignalingNewProposal: 'signaling-new-proposal', + SignalingCommitStarted: 'signaling-commit-started', + SignalingVotingStarted: 'signaling-voting-started', + SignalingVotingCompleted: 'signaling-voting-completed', + // TODO: do we want to track votes for signaling? + + TreasuryRewardMinting: 'treasury-reward-minting', + TreasuryRewardMintingV2: 'treasury-reward-minting-v2', +}; + +const initChainEventTypes = (queryInterface, Sequelize, t) => { + const buildObject = (event_name, chain) => ({ + id: `${chain}-${event_name}`, + chain, + event_name, + }); + const edgewareObjs = Object.values(SubstrateEventKinds).map((s) => buildObject(s, 'edgeware')); + + // TODO: somehow switch this on for testing purposes? + // const edgewareLocalObjs = Object.values(SubstrateEventKinds).map((s) => buildObject(s, 'edgeware-local')); + return queryInterface.bulkInsert( + 'ChainEventTypes', + [ + ...edgewareObjs, + // ...edgewareLocalObjs + ], + { transaction: t } + ); +}; + +module.exports = { + up: (queryInterface, Sequelize) => { + // add chain_event and chain_event_type tables + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.createTable('ChainEventTypes', { + id: { type: Sequelize.STRING, primaryKey: true }, + chain: { + type: Sequelize.STRING, + allowNull: false, + references: { model: 'Chains', key: 'id' }, + }, + event_name: { type: Sequelize.STRING, allowNull: false }, + }, { + transaction: t, + timestamps: false, + underscored: true, + indexes: [ + { fields: ['id'] }, + { fields: ['chain', 'event_name'] }, + ] + }); + + await queryInterface.createTable('ChainEvents', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + chain_event_type_id: { + type: Sequelize.STRING, + allowNull: false, + references: { model: 'ChainEventTypes', key: 'id' }, + }, + block_number: { type: Sequelize.INTEGER, allowNull: false }, + event_data: { type: Sequelize.JSONB, allowNull: false }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, { + transaction: t, + timestamps: true, + underscored: true, + indexes: [ + { fields: ['id'] }, + { fields: ['block_number', 'chain_event_type_id'] }, + ] + }); + + // add association on notifications + await queryInterface.addColumn('Notifications', 'chain_event_id', { + type: Sequelize.INTEGER, + allowNull: true, + references: { model: 'ChainEvents', key: 'id' }, + }, { transaction: t }); + + // add type to NotificationCategories + await queryInterface.bulkInsert('NotificationCategories', [{ + name: 'chain-event', + description: 'a chain event occurs', + created_at: new Date(), + updated_at: new Date(), + }], { transaction: t }); + + // TODO: TESTING ONLY + // await queryInterface.bulkInsert('Chains', [{ + // id: 'edgeware-local', + // network: 'edgeware', + // symbol: 'EDG', + // name: 'Edgeware Local', + // icon_url: '/static/img/protocols/edg.png', + // active: true, + // type: 'chain', + // }], { transaction: t }); + // await queryInterface.bulkInsert('ChainNodes', [{ + // chain: 'edgeware-local', + // url: 'localhost:9944', + // }], { transaction: t }); + + // initialize chain event types as needed + await initChainEventTypes(queryInterface, Sequelize, t); + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.bulkDelete('Notifications', { + chain_event_id: { + [Op.ne]: null + } + }, { transaction: t }); + await queryInterface.bulkDelete('Subscriptions', { + category_id: 'chain-event', + }, { transaction: t }); + // remove type from NotificationCategories + await queryInterface.bulkDelete('NotificationCategories', { + name: 'chain-event', + }, { transaction: t }); + + // remove association from notifications + await queryInterface.removeColumn('Notifications', 'chain_event_id', { transaction: t }); + + // remove chain_event and chain_event_type tables + await queryInterface.dropTable('ChainEvents', { transaction: t }); + await queryInterface.dropTable('ChainEventTypes', { transaction: t }); + }); + } +}; diff --git a/server/migrations/20200417182339-add-notif-category.js b/server/migrations/20200417182339-add-notif-category.js index 43134415f83..af750b0c3ce 100644 --- a/server/migrations/20200417182339-add-notif-category.js +++ b/server/migrations/20200417182339-add-notif-category.js @@ -1,6 +1,22 @@ 'use strict'; module.exports = { - up: (qI) => qI.sequelize.query(`INSERT INTO "NotificationCategories" (name, description) VALUES ('new-reaction', 'someone reacts to a post');`), - down: (qI) => qI.sequelize.query(`DELETE FROM "NotificationCategories" WHERE name='new-reaction';`), + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.bulkInsert('NotificationCategories', [{ + name: 'new-reaction', + description: 'someone reacts to a post', + created_at: new Date(), + updated_at: new Date(), + }], { transaction: t }); + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.bulkDelete('NotificationCategories', { + name: 'new-reaction', + }, { transaction: t }); + }); + } }; diff --git a/server/models/chain_event.ts b/server/models/chain_event.ts new file mode 100644 index 00000000000..43f3445d839 --- /dev/null +++ b/server/models/chain_event.ts @@ -0,0 +1,26 @@ +module.exports = (sequelize, DataTypes) => { + const ChainEvent = sequelize.define('ChainEvent', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + chain_event_type_id: { type: DataTypes.STRING, allowNull: false }, + block_number: { type: DataTypes.INTEGER, allowNull: false }, + + event_data: { type: DataTypes.JSONB, allowNull: false }, + created_at: { type: DataTypes.DATE, allowNull: false }, + updated_at: { type: DataTypes.DATE, allowNull: false }, + }, { + timestamps: true, + underscored: true, + paranoid: false, + indexes: [ + { fields: ['id'] }, + { fields: ['block_number', 'chain_event_type_id'] }, + ] + }); + + ChainEvent.associate = (models) => { + // master event type + models.ChainEvent.belongsTo(models.ChainEventType, { foreignKey: 'chain_event_type_id', targetKey: 'id' }); + }; + + return ChainEvent; +}; diff --git a/server/models/chain_event_type.ts b/server/models/chain_event_type.ts new file mode 100644 index 00000000000..e6347ab8be8 --- /dev/null +++ b/server/models/chain_event_type.ts @@ -0,0 +1,29 @@ +module.exports = (sequelize, DataTypes) => { + const ChainEventType = sequelize.define('ChainEventType', { + // id = chain-event_name (event_name is value of string enum) + id: { type: DataTypes.STRING, primaryKey: true }, + chain: { type: DataTypes.STRING, allowNull: false }, + event_name: { type: DataTypes.STRING, allowNull: false }, + }, { + timestamps: false, + underscored: true, + indexes: [ + { fields: ['id'] }, + { fields: ['chain', 'event_name'] }, + ] + }); + + ChainEventType.associate = (models) => { + // chain the event happens on + models.ChainEventType.belongsTo(models.Chain, { foreignKey: 'chain', targetKey: 'id' }); + + // many emitted events of this type + models.ChainEventType.hasMany(models.ChainEvent, { as: 'events' }); + + // many users subscribed to this event type + // TODO: this is currently unused, but could be useful? + // models.ChainEventType.hasMany(models.Subscription, { as: 'subscriptions' }); + }; + + return ChainEventType; +}; diff --git a/server/models/chain_node.ts b/server/models/chain_node.ts index 7ae3ff975d7..155b89fa40e 100644 --- a/server/models/chain_node.ts +++ b/server/models/chain_node.ts @@ -13,7 +13,7 @@ module.exports = (sequelize, DataTypes) => { ChainNode.associate = (models) => { models.ChainNode.belongsTo(models.Chain, { foreignKey: 'chain' }); -}; + }; return ChainNode; }; diff --git a/server/models/notification.ts b/server/models/notification.ts index 6aa25ed8792..142994c0389 100644 --- a/server/models/notification.ts +++ b/server/models/notification.ts @@ -3,6 +3,7 @@ module.exports = (sequelize, DataTypes) => { subscription_id: { type: DataTypes.INTEGER, allowNull: false }, notification_data: { type: DataTypes.TEXT, allowNull: false }, is_read: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false }, + chain_event_id: { type: DataTypes.INTEGER, allowNull: true }, }, { underscored: true, indexes: [ @@ -12,6 +13,7 @@ module.exports = (sequelize, DataTypes) => { Notification.associate = (models) => { models.Notification.belongsTo(models.Subscription, { foreignKey: 'subscription_id', targetKey: 'id' }); + models.Notification.belongsTo(models.ChainEvent, { foreignKey: 'chain_event_id', targetKey: 'id' }); }; return Notification; diff --git a/server/models/subscription.ts b/server/models/subscription.ts index d146cdd7a67..65b4c82b603 100644 --- a/server/models/subscription.ts +++ b/server/models/subscription.ts @@ -55,32 +55,66 @@ module.exports = (sequelize, DataTypes) => { notification_data: IPostNotificationData | ICommunityNotificationData, webhook_data: WebhookContent, wss?, + excludeAddresses?: string[], + includeAddresses?: string[], + chainEventId?: number, ) => { - const creatorAddress = await models.Address.findOne({ - where: { - address: notification_data.author_address, - }, - }); - // get subscribers to send notifications to - const subscribers = await models.Subscription.findAll({ - where: { - [Op.and]: [ - { category_id }, - { object_id }, - { is_active: true }, - ], - [Op.not]: [{ subscriber_id: creatorAddress.user_id }], - }, - }); + const findOptions: any = { + [Op.and]: [ + { category_id }, + { object_id }, + { is_active: true }, + ], + }; + + const fetchUsersFromAddresses = async (addresses: string[]): Promise => { + // fetch user ids from address models + const addressModels = await models.Address.findAll({ + where: { + address: { + [Op.in]: addresses, + }, + }, + }); + if (addressModels && addressModels.length > 0) { + const userIds = addressModels.map((a) => a.user_id); + + // remove duplicates + const userIdsDedup = userIds.filter((a, b) => userIds.indexOf(a) === b); + return userIdsDedup; + } else { + return []; + } + }; + + // currently excludes override includes, but we may want to provide the option for both + if (excludeAddresses && excludeAddresses.length > 0) { + const ids = await fetchUsersFromAddresses(excludeAddresses); + if (ids && ids.length > 0) { + findOptions[Op.and].push({ subscriber_id: { [Op.notIn]: ids } }); + } + } else if (includeAddresses && includeAddresses.length > 0) { + const ids = await fetchUsersFromAddresses(includeAddresses); + if (ids && ids.length > 0) { + findOptions[Op.and].push({ subscriber_id: { [Op.in]: ids } }); + } + } + + const subscribers = await models.Subscription.findAll({ where: findOptions }); // create notifications if data exists + let notifications = []; if (notification_data) { - await Promise.all(subscribers.map(async (subscription) => { - const notification = await models.Notification.create({ + notifications = await Promise.all(subscribers.map(async (subscription) => { + const notificationObj: any = { subscription_id: subscription.id, notification_data: JSON.stringify(notification_data), - }); + }; + if (chainEventId) { + notificationObj.chain_event_id = chainEventId; + } + const notification = await models.Notification.create(notificationObj); return notification; })); } @@ -100,6 +134,7 @@ module.exports = (sequelize, DataTypes) => { notificationCategory: category_id, ...webhook_data }); + return notifications; }; return Subscription; diff --git a/server/routes/createComment.ts b/server/routes/createComment.ts index b5c940325d4..63a732df7a8 100644 --- a/server/routes/createComment.ts +++ b/server/routes/createComment.ts @@ -193,6 +193,7 @@ const createComment = async (models, req: UserRequest, res: Response, next: Next community: finalComment.community, }, req.wss, + [ finalComment.Address.address ], ); // if child comment, dispatch notification to parent author @@ -223,6 +224,7 @@ const createComment = async (models, req: UserRequest, res: Response, next: Next community: finalComment.community, }, req.wss, + [ finalComment.Address.address ], ); } @@ -258,7 +260,8 @@ const createComment = async (models, req: UserRequest, res: Response, next: Next author_address: finalComment.Address.address, author_chain: finalComment.Address.chain, }, - req.wss + req.wss, + [ finalComment.Address.address ], ); })); } diff --git a/server/routes/createCommunity.ts b/server/routes/createCommunity.ts index d1a97613aa3..c7290e61a09 100644 --- a/server/routes/createCommunity.ts +++ b/server/routes/createCommunity.ts @@ -84,6 +84,7 @@ const createCommunity = async (models, req: UserRequest, res: Response, next: Ne community: req.body.name, }, req.wss, + [ req.body.creator_address ], ); return res.json({ status: 'Success', result: community.toJSON() }); diff --git a/server/routes/createReaction.ts b/server/routes/createReaction.ts index 14ff4a6a6de..30bb3041e22 100644 --- a/server/routes/createReaction.ts +++ b/server/routes/createReaction.ts @@ -98,6 +98,7 @@ const createReaction = async (models, req: UserRequest, res: Response, next: Nex community: finalReaction.community, }, req.wss, + [ finalReaction.Address.address ], ); return res.json({ status: 'Success', result: finalReaction.toJSON() }); diff --git a/server/routes/createThread.ts b/server/routes/createThread.ts index 9db4a18fce2..0bce16a6927 100644 --- a/server/routes/createThread.ts +++ b/server/routes/createThread.ts @@ -189,6 +189,7 @@ const createThread = async (models, req: UserRequest, res: Response, next: NextF community: finalThread.community, }, req.wss, + [ finalThread.Address.address ], ); // grab mentions to notify tagged users @@ -240,7 +241,8 @@ const createThread = async (models, req: UserRequest, res: Response, next: NextF author_address: finalThread.Address.address, author_chain: finalThread.Address.chain, }, - req.wss + req.wss, + [ finalThread.Address.address ], ); })); diff --git a/server/routes/editComment.ts b/server/routes/editComment.ts index 83919b3a258..c06d4f1cf26 100644 --- a/server/routes/editComment.ts +++ b/server/routes/editComment.ts @@ -95,6 +95,7 @@ const editComment = async (models, req: UserRequest, res: Response, next: NextFu community: finalComment.community, }, req.wss, + [ finalComment.Address.address ], ); return res.json({ status: 'Success', result: finalComment.toJSON() }); diff --git a/server/routes/editThread.ts b/server/routes/editThread.ts index b663f13ce7b..ac9bfaa415c 100644 --- a/server/routes/editThread.ts +++ b/server/routes/editThread.ts @@ -77,6 +77,7 @@ const editThread = async (models, req: UserRequest, res: Response, next: NextFun // don't send webhook notifications for edits null, req.wss, + [ finalThread.Address.address ], ); return res.json({ status: 'Success', result: finalThread.toJSON() }); } catch (e) { diff --git a/server/routes/viewNotifications.ts b/server/routes/viewNotifications.ts index 71879e11cb4..22b82386b2c 100644 --- a/server/routes/viewNotifications.ts +++ b/server/routes/viewNotifications.ts @@ -29,6 +29,16 @@ export default async (models, req: UserRequest, res: Response, next: NextFunctio const includeParams: any = { model: models.Notification, as: 'Notifications', + include: [{ + model: models.ChainEvent, + required: false, + as: 'ChainEvent', + include: [{ + model: models.ChainEventType, + required: false, + as: 'ChainEventType', + }], + }] }; if (req.body.unread_only) { includeParams.where = { is_read: false }; diff --git a/server/scripts/resetServer.ts b/server/scripts/resetServer.ts index 2d6b724d229..e102b7305c3 100644 --- a/server/scripts/resetServer.ts +++ b/server/scripts/resetServer.ts @@ -3,6 +3,7 @@ import { NotificationCategories } from '../../shared/types'; import { ADDRESS_TOKEN_EXPIRES_IN } from '../config'; import addChainObjectQueries from './addChainObjectQueries'; import app from '../../server'; +import { SubstrateEventKinds } from '../../shared/events/edgeware/types'; import { factory, formatFilename } from '../util/logging'; const log = factory.getLogger(formatFilename(__filename)); @@ -12,7 +13,7 @@ const nodes = [ [ 'berlin2.edgewa.re', 'edgeware-testnet' ], [ 'berlin3.edgewa.re', 'edgeware-testnet' ], [ 'mainnet1.edgewa.re', 'edgeware' ], - //[ 'localhost:9944', 'kusama-local' ], + // [ 'localhost:9944', 'kusama-local' ], [ 'wss://kusama-rpc.polkadot.io', 'kusama' ], [ 'ws://127.0.0.1:7545', 'ethereum-local' ], [ 'wss://mainnet.infura.io/ws', 'ethereum' ], @@ -303,6 +304,10 @@ const resetServer = (models, closeMiddleware) => { name: NotificationCategories.NewReaction, description: 'someone reacts to a post', }); + await models.NotificationCategory.create({ + name: NotificationCategories.ChainEvent, + description: 'a chain event occurs', + }); // Admins need to be subscribed to mentions await models.Subscription.create({ @@ -383,6 +388,22 @@ const resetServer = (models, closeMiddleware) => { await Promise.all(nodes.map(([ url, chain, address ]) => (models.ChainNode.create({ chain, url, address })))); + // initialize chain event types + const initChainEventTypes = (chain) => { + return Promise.all( + SubstrateEventKinds.map((event_name) => { + return models.ChainEventType.create({ + id: `${chain}-${event_name}`, + chain, + event_name, + }); + }) + ); + }; + + await initChainEventTypes('edgeware'); + await initChainEventTypes('edgeware-local'); + closeMiddleware().then(() => { log.debug('Reset database and initialized default models'); process.exit(0); diff --git a/server/scripts/setupChainEventListeners.ts b/server/scripts/setupChainEventListeners.ts new file mode 100644 index 00000000000..4f8a12db4d8 --- /dev/null +++ b/server/scripts/setupChainEventListeners.ts @@ -0,0 +1,53 @@ +import EdgewareEventHandler from '../eventHandlers/edgeware'; +import subscribeEdgewareEvents from '../../shared/events/edgeware/index'; +import { IDisconnectedRange } from '../../shared/events/interfaces'; + +const discoverReconnectRange = async (models, chain: string): Promise => { + const lastChainEvent = await models.ChainEvent.findAll({ + limit: 1, + order: [ [ 'created_at', 'DESC' ]], + // this $...$ queries the data inside the include (ChainEvents don't have `chain` but ChainEventTypes do)... + // we might be able to replicate this behavior with where and required: true inside the include + where: { + '$ChainEventType.chain$': chain, + }, + include: [ + { model: models.ChainEventType } + ] + }); + if (lastChainEvent && lastChainEvent.length > 0 && lastChainEvent[0]) { + const lastEventBlockNumber = lastChainEvent[0].block_number; + console.log(`Discovered chain event in db at block ${lastEventBlockNumber}.`); + return { startBlock: lastEventBlockNumber + 1 }; + } else { + return { startBlock: null }; + } +}; + +const setupChainEventListeners = async (models, wss, skipCatchup = false) => { + // TODO: add a flag to the db for this filter, but for now + // just take edgeware and edgeware-local + console.log('Fetching node urls...'); + const nodes = await models.ChainNode.findAll(); + console.log('Setting up event listeners...'); + await Promise.all(nodes.filter((node) => node.chain === 'edgeware' || node.chain === 'edgeware-local') + .map(async (node) => { + const eventHandler = new EdgewareEventHandler(models, wss, node.chain); + let url = node.url.substr(0, 2) === 'ws' ? node.url : `ws://${node.url}`; + url = (url.indexOf(':9944') !== -1) ? url : `${url}:9944`; + const subscriber = await subscribeEdgewareEvents( + url, + eventHandler, + skipCatchup, + () => discoverReconnectRange(models, node.chain), + ); + + // hook for clean exit + process.on('SIGTERM', () => { + subscriber.unsubscribe(); + }); + return subscriber; + })); +}; + +export default setupChainEventListeners; diff --git a/shared/adapters/currency.ts b/shared/adapters/currency.ts index 45cf86ef7da..b55da7eae0e 100644 --- a/shared/adapters/currency.ts +++ b/shared/adapters/currency.ts @@ -1,7 +1,7 @@ import BN from 'bn.js'; // duplicated in helpers.ts -function formatNumberShort(num: number) { +export function formatNumberShort(num: number) { const round = (n, digits?) => { if (digits === undefined) digits = 2; return Math.round(n * Math.pow(10, digits)) / Math.pow(10, digits); diff --git a/shared/events/README.md b/shared/events/README.md new file mode 100644 index 00000000000..1f66433d55e --- /dev/null +++ b/shared/events/README.md @@ -0,0 +1,29 @@ +# Introduction + +The purpose of the "events" modules are to hook various chains into the Commonwealth notifications system, such that incoming chain events (like staking rewards, governance actions, etc) trigger the creation and display of user-facing notifications. + +# Adding a new event to an existing chain + +Adding a new Edgeware event requires the following steps: + +1. Add a new item to the `SubstrateEventKind` enum in [types.ts](edgeware/types.ts) +1. Create an appropriate interface for the event's data in [types.ts](edgeware/types.ts). The `kind` string should be the previously added item from `SubstrateEventKind`. +2. Add the new interface to the `ISubstrateEventData` union type. +3. Add the new event type to the filters, as follows: + * In [type_parser.ts](edgeware/filters/type_parser.ts), you must add a new mapping from the on-chain event's section and method strings to the `SubstrateEventKind` item added in step 1. + * In [enricher.ts](edgeware/filters/enricher.ts), you must add a new case for the `SubstrateEventKind` item added in step 1, and return an object as described by the event's interface. If additional chain data is needed, this is where you should fetch it. + * In [titler.ts](edgeware/filters/titler.ts), you must add a new case for the `SubstrateEventKind` item added in step 1, which returns an event title and description of that kind of event. Note that this is a type-wide title, not specific to individual instances of the event. + * In [labeler.ts](edgeware/filters/labeler.ts), you must add a new case for the `SubstrateEventKind` item added in step 1, which uses the event's object to return a new object as described by `IEventLabel`. The `heading` should be considered the notification title, the `label` should be considered the notification description and the `url` should be considered an onclick link for the notification. +4. Test out your change by triggering the event on a local testnet, and/or by writing a test case for the processor, in [processor.spec.ts](../../test/unit/events/edgeware/processor.spec.ts). + +# Adding a new chain + +A new chain must include all components specified in [interfaces.ts](interfaces.ts): a set of types, a subscriber, a poller, a processor, and an event handler, as well as a filters folder that includes a labeler and a titler. + +A new chain should also include an index file that initializes the chain's event handling setup. + +The new chain should use the same style of type definition as Edgeware, a set of interfaces with string enum kinds. The new chain should expose a union of interface types that should be added to the `IChainEventData` and `IChainEventKind` union types in [interfaces.ts](interfaces.ts). The new chain's "id" string should also be added to the `EventSupportingChains` array in [interfaces.ts](interfaces.ts), although we might shift to a database-oriented approach in the future. + +The new labeler function should be added to [header.ts](../../client/scripts/views/components/header.ts) as a separate import, and should be conditionally invoked as needed. + +The new titler function and chain should be added to [subscriptions.ts](../../client/scripts/view/pages/subscriptions.ts), specifically as cases in the `EventSubscriptions` Mithril object. \ No newline at end of file diff --git a/shared/events/edgeware/filters/enricher.ts b/shared/events/edgeware/filters/enricher.ts new file mode 100644 index 00000000000..3852ee42689 --- /dev/null +++ b/shared/events/edgeware/filters/enricher.ts @@ -0,0 +1,467 @@ +import { ApiPromise } from '@polkadot/api'; +import { + Event, ReferendumInfoTo239, AccountId, TreasuryProposal, Balance, PropIndex, + ReferendumIndex, ProposalIndex, VoteThreshold, Hash, BlockNumber, Votes, Extrinsic +} from '@polkadot/types/interfaces'; +import { ProposalRecord } from 'edgeware-node-types/dist/types'; +import { Option, bool, Vec, u32, u64 } from '@polkadot/types'; +import { Codec } from '@polkadot/types/types'; +import { SubstrateEventKind, ISubstrateEventData, isEvent } from '../types'; +import { CWEvent } from '../../interfaces'; + +/** + * This is an "enricher" function, whose goal is to augment the initial event data + * received from the "system.events" query with additional useful information, as + * described in the event's interface in our "types.ts" file. + * + * Once fetched, the function marshalls the event data and the additional information + * into the interface, and returns a fully-formed event, ready for database storage. + */ +export default async function ( + api: ApiPromise, + blockNumber: number, + kind: SubstrateEventKind, + rawData: Event | Extrinsic, +): Promise { + const extractEventData = async (event: Event): Promise<{ + data: ISubstrateEventData, + includeAddresses?: string[], + excludeAddresses?: string[], + }> => { + switch (kind) { + /** + * Staking Events + */ + case SubstrateEventKind.Reward: { + if (event.data.typeDef[0].type === 'Balance') { + // edgeware/old event + const [ amount, remainder ] = event.data as unknown as [ Balance, Balance ] & Codec; + return { + data: { + kind, + amount: amount.toString(), + } + }; + } else { + // kusama/new event + const [ validator, amount ] = event.data as unknown as [ AccountId, Balance ] & Codec; + return { + includeAddresses: [ validator.toString() ], + data: { + kind, + validator: validator.toString(), + amount: amount.toString(), + } + }; + } + } + case SubstrateEventKind.Slash: { + const [ validator, amount ] = event.data as unknown as [ AccountId, Balance ] & Codec; + return { + includeAddresses: [ validator.toString() ], + data: { + kind, + validator: validator.toString(), + amount: amount.toString(), + } + }; + } + + case SubstrateEventKind.Bonded: + case SubstrateEventKind.Unbonded: { + const [ stash, amount ] = event.data as unknown as [ AccountId, Balance ] & Codec; + const controllerOpt = await api.query.staking.bonded>(stash); + if (!controllerOpt.isSome) { + throw new Error(`could not fetch staking controller for ${stash.toString()}`); + } + return { + includeAddresses: [ stash.toString() ], + data: { + kind, + stash: stash.toString(), + amount: amount.toString(), + controller: controllerOpt.unwrap().toString(), + } + }; + } + + /** + * Democracy Events + */ + case SubstrateEventKind.VoteDelegated: { + const [ who, target ] = event.data as unknown as [ AccountId, AccountId ] & Codec; + return { + includeAddresses: [ target.toString() ], + data: { + kind, + who: who.toString(), + target: target.toString(), + } + }; + } + + case SubstrateEventKind.DemocracyProposed: { + const [ proposalIndex, deposit ] = event.data as unknown as [ PropIndex, Balance ] & Codec; + const props = await api.query.democracy.publicProps(); + const prop = props.find((p) => p.length > 0 && +p[0] === +proposalIndex); + if (!prop) { + throw new Error(`could not fetch info for proposal ${+proposalIndex}`); + } + const [ idx, hash, proposer ] = prop; + return { + excludeAddresses: [ proposer.toString() ], + data: { + kind, + proposalIndex: +proposalIndex, + proposalHash: hash.toString(), + deposit: deposit.toString(), + proposer: proposer.toString(), + } + }; + } + + case SubstrateEventKind.DemocracyTabled: { + const [ proposalIndex ] = event.data as unknown as [ PropIndex, Balance, Vec ] & Codec; + return { + data: { + kind, + proposalIndex: +proposalIndex, + } + }; + } + + case SubstrateEventKind.DemocracyStarted: { + const [ referendumIndex, voteThreshold ] = event.data as unknown as [ ReferendumIndex, VoteThreshold ] & Codec; + + // query for edgeware only -- kusama has different type + const infoOpt = await api.query.democracy.referendumInfoOf>(referendumIndex); + if (!infoOpt.isSome) { + throw new Error(`could not find info for referendum ${+referendumIndex}`); + } + return { + data: { + kind, + referendumIndex: +referendumIndex, + proposalHash: infoOpt.unwrap().hash.toString(), + voteThreshold: voteThreshold.toString(), + endBlock: +infoOpt.unwrap().end, + } + }; + } + + case SubstrateEventKind.DemocracyPassed: { + const [ referendumIndex ] = event.data as unknown as [ ReferendumIndex ] & Codec; + // dispatch queue -- if not present, it was already executed + const dispatchQueue = await api.derive.democracy.dispatchQueue(); + const dispatchInfo = dispatchQueue.find(({ index }) => +index === +referendumIndex); + return { + data: { + kind, + referendumIndex: +referendumIndex, + dispatchBlock: dispatchInfo ? +dispatchInfo.at : null, + } + }; + } + + case SubstrateEventKind.DemocracyNotPassed: + case SubstrateEventKind.DemocracyCancelled: { + const [ referendumIndex ] = event.data as unknown as [ ReferendumIndex ] & Codec; + return { + data: { + kind, + referendumIndex: +referendumIndex, + } + }; + } + + case SubstrateEventKind.DemocracyExecuted: { + const [ referendumIndex, executionOk ] = event.data as unknown as [ ReferendumIndex, bool ] & Codec; + return { + data: { + kind, + referendumIndex: +referendumIndex, + executionOk: executionOk.isTrue, + } + }; + } + + /** + * Preimage Events + */ + case SubstrateEventKind.PreimageNoted: { + const [ hash, noter, deposit ] = event.data as unknown as [ Hash, AccountId, Balance ] & Codec; + return { + excludeAddresses: [ noter.toString() ], + data: { + kind, + proposalHash: hash.toString(), + noter: noter.toString(), + } + }; + } + case SubstrateEventKind.PreimageUsed: { + const [ hash, noter, deposit ] = event.data as unknown as [ Hash, AccountId, Balance ] & Codec; + return { + data: { + kind, + proposalHash: hash.toString(), + noter: noter.toString(), + } + }; + } + case SubstrateEventKind.PreimageInvalid: + case SubstrateEventKind.PreimageMissing: { + const [ hash, referendumIndex ] = event.data as unknown as [ Hash, ReferendumIndex ] & Codec; + return { + data: { + kind, + proposalHash: hash.toString(), + referendumIndex: +referendumIndex, + } + }; + } + case SubstrateEventKind.PreimageReaped: { + const [ + hash, + noter, + deposit, + reaper, + ] = event.data as unknown as [ Hash, AccountId, Balance, AccountId ] & Codec; + return { + excludeAddresses: [ reaper.toString() ], + data: { + kind, + proposalHash: hash.toString(), + noter: noter.toString(), + reaper: reaper.toString(), + } + }; + } + + /** + * Treasury Events + */ + case SubstrateEventKind.TreasuryProposed: { + const [ proposalIndex ] = event.data as unknown as [ ProposalIndex ] & Codec; + const proposalOpt = await api.query.treasury.proposals>(proposalIndex); + if (!proposalOpt.isSome) { + throw new Error(`could not fetch treasury proposal index ${+proposalIndex}`); + } + const proposal = proposalOpt.unwrap(); + return { + excludeAddresses: [ proposal.proposer.toString() ], + data: { + kind, + proposalIndex: +proposalIndex, + proposer: proposal.proposer.toString(), + value: proposal.value.toString(), + beneficiary: proposal.beneficiary.toString(), + } + }; + } + + case SubstrateEventKind.TreasuryAwarded: { + const [ + proposalIndex, + amount, + beneficiary, + ] = event.data as unknown as [ ProposalIndex, Balance, AccountId ] & Codec; + return { + data: { + kind, + proposalIndex: +proposalIndex, + value: amount.toString(), + beneficiary: beneficiary.toString(), + } + }; + } + + case SubstrateEventKind.TreasuryRejected: { + const [ proposalIndex, slashedBond ] = event.data as unknown as [ ProposalIndex, Balance ] & Codec; + return { + data: { + kind, + proposalIndex: +proposalIndex, + } + }; + } + + /** + * Elections Events + */ + case SubstrateEventKind.ElectionNewTerm: { + const [ newMembers ] = event.data as unknown as [ Vec<[ AccountId, Balance ] & Codec> ] & Codec; + return { + data: { + kind, + newMembers: newMembers.map(([ who ]) => who.toString()), + } + }; + } + case SubstrateEventKind.ElectionEmptyTerm: { + return { data: { kind } }; + } + case SubstrateEventKind.ElectionMemberKicked: + case SubstrateEventKind.ElectionMemberRenounced: { + const [ who ] = event.data as unknown as [ AccountId ] & Codec; + return { + data: { + kind, + who: who.toString(), + } + }; + } + + /** + * Collective Events + */ + case SubstrateEventKind.CollectiveProposed: { + const [ + proposer, + index, + hash, + threshold, + ] = event.data as unknown as [ AccountId, ProposalIndex, Hash, u32 ] & Codec; + return { + excludeAddresses: [ proposer.toString() ], + data: { + kind, + proposer: proposer.toString(), + proposalIndex: +index, + proposalHash: hash.toString(), + threshold: +threshold, + } + }; + } + case SubstrateEventKind.CollectiveApproved: + case SubstrateEventKind.CollectiveDisapproved: { + const [ hash ] = event.data as unknown as [ Hash ] & Codec; + const infoOpt = await api.query.council.voting>(hash); + if (!infoOpt.isSome) { + throw new Error('could not fetch info for collective proposal'); + } + const { index, threshold, ayes, nays } = infoOpt.unwrap(); + return { + data: { + kind, + proposalHash: hash.toString(), + proposalIndex: +index, + threshold: +threshold, + ayes: ayes.map((v) => v.toString()), + nays: nays.map((v) => v.toString()), + } + }; + } + case SubstrateEventKind.CollectiveExecuted: + case SubstrateEventKind.CollectiveMemberExecuted: { + const [ hash, executionOk ] = event.data as unknown as [ Hash, bool ] & Codec; + return { + data: { + kind, + proposalHash: hash.toString(), + executionOk: executionOk.isTrue, + } + }; + } + + /** + * Signaling Events + */ + case SubstrateEventKind.SignalingNewProposal: { + const [ proposer, hash ] = event.data as unknown as [ AccountId, Hash ] & Codec; + const proposalInfoOpt = await api.query.signaling.proposalOf>(hash); + if (!proposalInfoOpt.isSome) { + throw new Error('unable to fetch signaling proposal info'); + } + return { + excludeAddresses: [ proposer.toString() ], + data: { + kind, + proposer: proposer.toString(), + proposalHash: hash.toString(), + voteId: proposalInfoOpt.unwrap().vote_id.toString(), + // TODO: add title/contents? + } + }; + } + case SubstrateEventKind.SignalingCommitStarted: + case SubstrateEventKind.SignalingVotingStarted: { + const [ hash, voteId, endBlock ] = event.data as unknown as [ Hash, u64, BlockNumber ] & Codec; + return { + data: { + kind, + proposalHash: hash.toString(), + voteId: voteId.toString(), + endBlock: +endBlock, + } + }; + } + case SubstrateEventKind.SignalingVotingCompleted: { + const [ hash, voteId ] = event.data as unknown as [ Hash, u64 ] & Codec; + return { + data: { + kind, + proposalHash: hash.toString(), + voteId: voteId.toString(), + } + }; + } + + /** + * TreasuryReward events + */ + case SubstrateEventKind.TreasuryRewardMinting: { + const [ pot, reward, blockNum ] = event.data as unknown as [ Balance, Balance, BlockNumber ] & Codec; + return { + data: { + kind, + pot: pot.toString(), + reward: reward.toString(), + } + }; + } + case SubstrateEventKind.TreasuryRewardMintingV2: { + const [ pot, blockNum, potAddress ] = event.data as unknown as [ Balance, BlockNumber, AccountId ] & Codec; + return { + data: { + kind, + pot: pot.toString(), + potAddress: potAddress.toString(), + } + }; + } + + default: { + throw new Error(`unknown event type: ${kind}`); + } + } + }; + + const extractExtrinsicData = async (extrinsic: Extrinsic): Promise<{ + data: ISubstrateEventData, + includeAddresses?: string[], + excludeAddresses?: string[], + }> => { + switch (kind) { + case SubstrateEventKind.ElectionCandidacySubmitted: { + const candidate = extrinsic.signer.toString(); + return { + excludeAddresses: [ candidate ], + data: { + kind, + candidate, + } + }; + } + default: { + throw new Error(`unknown event type: ${kind}`); + } + } + }; + + const eventData = await (isEvent(rawData) + ? extractEventData(rawData as Event) + : extractExtrinsicData(rawData as Extrinsic) + ); + return { ...eventData, blockNumber }; +} diff --git a/shared/events/edgeware/filters/labeler.ts b/shared/events/edgeware/filters/labeler.ts new file mode 100644 index 00000000000..4518d203cd6 --- /dev/null +++ b/shared/events/edgeware/filters/labeler.ts @@ -0,0 +1,386 @@ +import BN from 'bn.js'; + +import { SubstrateBalanceString, SubstrateEventKind } from '../types'; +import { IEventLabel, IChainEventData, LabelerFilter } from '../../interfaces'; +import { SubstrateCoin } from '../../../adapters/chain/substrate/types'; + +function fmtAddr(addr : string) { + if (!addr) return; + if (addr.length < 16) return addr; + return `${addr.slice(0, 5)}…${addr.slice(addr.length - 3)}`; +} + +// ideally we shouldn't hard-code this stuff, but we need the header to appear before the chain loads +const EDG_DECIMAL = 18; + +const edgBalanceFormatter = (chain, balance: SubstrateBalanceString): string => { + const denom = chain === 'edgeware' + ? 'EDG' + : chain === 'edgeware-local' || chain === 'edgeware-testnet' + ? 'tEDG' : null; + if (!denom) { + throw new Error('unexpected chain'); + } + const dollar = (new BN(10)).pow(new BN(EDG_DECIMAL)); + const coin = new SubstrateCoin(denom, new BN(balance, 10), dollar); + return coin.format(true); +}; + +/* eslint-disable max-len */ +/** + * This a labeler function, which takes event data and describes it in "plain english", + * such that we can display a notification regarding its contents. + */ +const labelEdgewareEvent: LabelerFilter = ( + blockNumber: number, + chainId: string, + data: IChainEventData, +): IEventLabel => { + const balanceFormatter = (bal) => edgBalanceFormatter(chainId, bal); + switch (data.kind) { + /** + * Staking Events + */ + case SubstrateEventKind.Slash: { + const { validator, amount } = data; + return { + heading: 'Validator Slashed', + label: `Validator ${fmtAddr(validator)} was slashed by amount ${balanceFormatter(amount)}.`, + }; + } + case SubstrateEventKind.Reward: { + const { amount } = data; + return { + heading: 'Validator Rewarded', + label: data.validator + ? `Validator ${fmtAddr(data.validator)} was rewarded by amount ${balanceFormatter(amount)}.` + : `All validators were rewarded by amount ${balanceFormatter(amount)}.`, + }; + } + case SubstrateEventKind.Bonded: { + const { stash, controller, amount } = data; + return { + heading: 'Bonded', + label: `You bonded ${balanceFormatter(amount)} from controller ${fmtAddr(controller)} to stash ${fmtAddr(stash)}.`, + }; + } + case SubstrateEventKind.Unbonded: { + const { stash, controller, amount } = data; + return { + heading: 'Bonded', + label: `You unbonded ${balanceFormatter(amount)} from controller ${fmtAddr(controller)} to stash ${fmtAddr(stash)}.`, + }; + } + + /** + * Democracy Events + */ + case SubstrateEventKind.VoteDelegated: { + const { who, target } = data; + return { + heading: 'Vote Delegated', + label: `Your account ${fmtAddr(target)} received a voting delegation from ${fmtAddr(who)}.` + }; + } + case SubstrateEventKind.DemocracyProposed: { + const { deposit, proposalIndex } = data; + return { + heading: 'Democracy Proposal Created', + label: `A new Democracy proposal was introduced with deposit ${balanceFormatter(deposit)}.`, + linkUrl: chainId ? `/${chainId}/proposal/democracyproposal/${proposalIndex}` : null, + }; + } + case SubstrateEventKind.DemocracyTabled: { + const { proposalIndex } = data; + return { + heading: 'Democracy Proposal Tabled', + label: `Democracy proposal ${proposalIndex} has been tabled as a referendum.`, + linkUrl: chainId ? `/${chainId}/proposal/democracyproposal/${proposalIndex}` : null, + // TODO: the only way to get the linkUrl here for the new referendum is to fetch the hash + // from the Proposed event, and then use that to discover the Started event which occurs + // *after* this, or else to fetch the not-yet-created referendum from storage. + // Once we have the referendum, we can use *that* index to generate a link. Or, we can + // just link to the completed proposal, as we do now. + }; + } + case SubstrateEventKind.DemocracyStarted: { + const { endBlock, referendumIndex } = data; + return { + heading: 'Democracy Referendum Started', + label: endBlock + ? `Referendum ${referendumIndex} launched, and will be voting until block ${endBlock}.` + : `Referendum ${referendumIndex} launched.`, + linkUrl: chainId ? `/${chainId}/proposal/referendum/${referendumIndex}` : null, + }; + } + case SubstrateEventKind.DemocracyPassed: { + const { dispatchBlock, referendumIndex } = data; + return { + heading: 'Democracy Referendum Passed', + label: dispatchBlock + ? `Referendum ${referendumIndex} passed and will be dispatched on block ${dispatchBlock}.` + : `Referendum ${referendumIndex} passed was dispatched on block ${blockNumber}.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.DemocracyNotPassed: { + const { referendumIndex } = data; + return { + heading: 'Democracy Referendum Failed', + // TODO: include final tally? + label: `Referendum ${referendumIndex} has failed.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.DemocracyCancelled: { + const { referendumIndex } = data; + return { + heading: 'Democracy Referendum Cancelled', + // TODO: include cancellation vote? + label: `Referendum ${referendumIndex} was cancelled.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.DemocracyExecuted: { + const { referendumIndex, executionOk } = data; + return { + heading: 'Democracy Referendum Executed', + label: `Referendum ${referendumIndex} was executed ${executionOk ? 'successfully' : 'unsuccessfully'}.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + + /** + * Preimage Events + */ + case SubstrateEventKind.PreimageNoted: { + const { proposalHash, noter } = data; + return { + heading: 'Preimage Noted', + label: `A new preimage was noted by ${fmtAddr(noter)}.`, + // TODO: the only way to get a link to (or text regarding) the related proposal here + // requires back-referencing the proposalHash with the index we use to identify the + // proposal. + // Alternatively, if we have a preimage-specific page (which would be nice, as we could + // display info about its corresponding Call), we can link to that, or we could instead + // link to the noter's profile. + }; + } + case SubstrateEventKind.PreimageUsed: { + const { proposalHash, noter } = data; + return { + heading: 'Preimage Used', + label: `A preimage noted by ${fmtAddr(noter)} was used.`, + // TODO: see linkUrl comment above, on PreimageNoted. + }; + } + case SubstrateEventKind.PreimageInvalid: { + const { proposalHash, referendumIndex } = data; + return { + heading: 'Preimage Invalid', + label: `Preimage for referendum ${referendumIndex} was invalid.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.PreimageMissing: { + const { proposalHash, referendumIndex } = data; + return { + heading: 'Preimage Missing', + label: `Preimage for referendum ${referendumIndex} not found.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.PreimageReaped: { + const { proposalHash, noter, reaper } = data; + return { + heading: 'Preimage Reaped', + label: `A preimage noted by ${fmtAddr(noter)} was reaped by ${fmtAddr(reaper)}.`, + }; + } + + /** + * Treasury Events + */ + case SubstrateEventKind.TreasuryProposed: { + const { proposalIndex, proposer, value } = data; + return { + heading: 'Treasury Proposal Created', + label: `Treasury proposal ${proposalIndex} was introduced by ${fmtAddr(proposer)} for ${balanceFormatter(value)}.`, + linkUrl: chainId ? `/${chainId}/proposal/treasuryproposal/${proposalIndex}` : null, + }; + } + case SubstrateEventKind.TreasuryAwarded: { + const { proposalIndex, value, beneficiary } = data; + return { + heading: 'Treasury Proposal Awarded', + label: `Treasury proposal ${proposalIndex} of ${balanceFormatter(value)} was awarded to ${fmtAddr(beneficiary)}.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.TreasuryRejected: { + const { proposalIndex } = data; + return { + heading: 'Treasury Proposal Rejected', + label: `Treasury proposal ${proposalIndex} was rejected.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + + /** + * Elections Events + * + * Note: all election events simply link to the council page. + * We may want to change this if deemed unnecessary. + */ + case SubstrateEventKind.ElectionNewTerm: { + const { newMembers } = data; + return { + heading: 'New Election Term Started', + label: `A new election term started with ${newMembers.length} new members.`, + // we just link to the council page here, so they can see the new members/results + linkUrl: chainId ? `/${chainId}/council/` : null, + }; + } + case SubstrateEventKind.ElectionEmptyTerm: { + return { + heading: 'New Election Term Started', + label: 'A new election term started with no new members.', + linkUrl: chainId ? `/${chainId}/council/` : null, + }; + } + case SubstrateEventKind.ElectionCandidacySubmitted: { + const { candidate } = data; + return { + heading: 'Council Candidate Submitted', + label: `${fmtAddr(candidate)} submitted a candidacy for council.`, + // TODO: this could also link to the member's page + linkUrl: chainId ? `/${chainId}/council` : null, + }; + } + case SubstrateEventKind.ElectionMemberKicked: { + const { who } = data; + return { + heading: 'Council Member Kicked', + label: `Council member ${fmtAddr(who)} was kicked at end of term.`, + // TODO: this could also link to the member's page + linkUrl: chainId ? `/${chainId}/council/` : null, + }; + } + case SubstrateEventKind.ElectionMemberRenounced: { + const { who } = data; + return { + heading: 'Council Member Renounced', + label: `Candidate ${fmtAddr(who)} renounced their candidacy.`, + // TODO: this could also link to the member's page + linkUrl: chainId ? `/${chainId}/council/` : null, + }; + } + + /** + * Collective Events + */ + case SubstrateEventKind.CollectiveProposed: { + const { proposer, proposalIndex, threshold } = data; + return { + heading: 'New Council Proposal', + label: `${fmtAddr(proposer)} introduced a new council proposal, requiring ${threshold} approvals to pass.`, + linkUrl: chainId ? `/${chainId}/proposal/councilmotion/${proposalIndex}` : null, + }; + } + case SubstrateEventKind.CollectiveApproved: { + const { proposalIndex, ayes, nays } = data; + return { + heading: 'Council Proposal Approved', + label: `Council proposal ${proposalIndex} was approved by vote ${ayes.length}-${nays.length}.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.CollectiveDisapproved: { + const { proposalIndex, ayes, nays } = data; + return { + heading: 'Council Proposal Disapproved', + label: `Council proposal ${proposalIndex} was disapproved by vote ${ayes.length}-${nays.length}.`, + // TODO: once we have proposal archiving, add linkUrl here + }; + } + case SubstrateEventKind.CollectiveExecuted: { + const { executionOk } = data; + return { + heading: 'Council Proposal Executed', + label: `Approved council proposal was executed ${executionOk ? 'successfully' : 'unsuccessfully'}.`, + // no way to recover the index here besides checking the db for proposed event + }; + } + case SubstrateEventKind.CollectiveMemberExecuted: { + const { executionOk } = data; + return { + heading: 'Council Proposal Executed', + label: `A member-executed council proposal was executed ${executionOk ? 'successfully' : 'unsuccessfully'}.`, + // no way to recover the index here besides checking the db for proposed event + // ...and for member-exectured proposals, that might not even exist, depending on logic + }; + } + + /** + * Signaling Events + */ + case SubstrateEventKind.SignalingNewProposal: { + const { proposer, voteId } = data; + return { + heading: 'New Signaling Proposal', + label: `A new signaling proposal was created by ${fmtAddr(proposer)}.`, + linkUrl: chainId ? `/${chainId}/proposal/signalingproposal/${voteId}` : null, + }; + } + case SubstrateEventKind.SignalingCommitStarted: { + const { endBlock, voteId } = data; + return { + heading: 'Signaling Proposal Commit Started', + label: `A signaling proposal's commit phase has started, lasting until block ${endBlock}.`, + linkUrl: chainId ? `/${chainId}/proposal/signalingproposal/${voteId}` : null, + }; + } + case SubstrateEventKind.SignalingVotingStarted: { + const { endBlock, voteId } = data; + return { + heading: 'Signaling Proposal Voting Started', + label: `A signaling proposal's voting phase has started, lasting until block ${endBlock}.`, + linkUrl: chainId ? `/${chainId}/proposal/signalingproposal/${voteId}` : null, + }; + } + case SubstrateEventKind.SignalingVotingCompleted: { + const { voteId } = data; + return { + heading: 'Signaling Proposal Completed', + label: 'A signaling proposal\'s voting phase has completed.', + linkUrl: chainId ? `/${chainId}/proposal/signalingproposal/${voteId}` : null, + }; + } + + /** + * TreasuryReward events + */ + case SubstrateEventKind.TreasuryRewardMinting: { + const { pot, reward } = data; + return { + heading: 'Treasury Reward Minted', + label: `A reward of size ${balanceFormatter(reward)} was minted. Treasury pot now of size ${balanceFormatter(pot)}.` + }; + } + case SubstrateEventKind.TreasuryRewardMintingV2: { + const { pot, potAddress } = data; + return { + heading: 'Treasury Reward Minted', + label: `A treasury reward was minted, pot now of size ${balanceFormatter(pot)}.` + }; + } + + default: { + // ensure exhaustive matching -- gives ts error if missing cases + const _exhaustiveMatch: never = data; + throw new Error('unknown event type'); + } + } +}; + +export default labelEdgewareEvent; diff --git a/shared/events/edgeware/filters/titler.ts b/shared/events/edgeware/filters/titler.ts new file mode 100644 index 00000000000..39c720fc1e9 --- /dev/null +++ b/shared/events/edgeware/filters/titler.ts @@ -0,0 +1,262 @@ +import { SubstrateEventKind } from '../types'; +import { IEventTitle, TitlerFilter } from '../../interfaces'; + +/** + * This a titler function, not to be confused with the labeler -- it takes a particular + * kind of event, and returns a "plain english" description of that type. This is used + * on the client to present a list of subscriptions that a user might want to subscribe to. + */ +const titlerFunc: TitlerFilter = (kind: SubstrateEventKind): IEventTitle => { + switch (kind) { + /** + * Staking Events + */ + case SubstrateEventKind.Slash: { + return { + title: 'Validator Slash', + description: 'Your validator is slashed.', + }; + } + case SubstrateEventKind.Reward: { + return { + title: 'Validator Reward', + description: 'Your validator is rewarded.', + }; + } + case SubstrateEventKind.Bonded: { + return { + title: 'Stash Bonded', + description: 'Your controller account bonds to a stash account.', + }; + } + case SubstrateEventKind.Unbonded: { + return { + title: 'Stash Unbonded', + description: 'Your controller account unbonds from a stash account.', + }; + } + + /** + * Democracy Events + */ + case SubstrateEventKind.VoteDelegated: { + return { + title: 'Vote Delegated', + description: 'You receive a voting delegation.', + }; + } + case SubstrateEventKind.DemocracyProposed: { + return { + title: 'Democracy Proposed', + description: 'A new community democracy proposal is introduced.', + }; + } + case SubstrateEventKind.DemocracyTabled: { + return { + title: 'Democracy Proposal Tabled', + description: 'A public democracy proposal is tabled to a referendum.', + }; + } + case SubstrateEventKind.DemocracyStarted: { + return { + title: 'Referendum Started', + description: 'A new democracy referendum started voting.', + }; + } + case SubstrateEventKind.DemocracyPassed: { + return { + title: 'Referendum Passed', + description: 'A democracy referendum finished voting and passed.', + }; + } + case SubstrateEventKind.DemocracyNotPassed: { + return { + title: 'Referendum Failed', + description: 'A democracy referendum finished voting and failed.', + }; + } + case SubstrateEventKind.DemocracyCancelled: { + return { + title: 'Referendum Cancelled', + description: 'A democracy referendum is cancelled.', + }; + } + case SubstrateEventKind.DemocracyExecuted: { + return { + title: 'Referendum Executed', + description: 'A passed democracy referendum is executed on chain.', + }; + } + + /** + * Preimage Events + */ + case SubstrateEventKind.PreimageNoted: { + return { + title: 'Preimage Noted', + description: 'A preimage is noted for a democracy referendum.', + }; + } + case SubstrateEventKind.PreimageUsed: { + return { + title: 'Preimage Used', + description: 'A democracy referendum\'s execution uses a preimage.', + }; + } + case SubstrateEventKind.PreimageInvalid: { + return { + title: 'Preimage Invalid', + description: 'A democracy referendum\'s execution was attempted but the preimage is invalid.', + }; + } + case SubstrateEventKind.PreimageMissing: { + return { + title: 'Preimage Missing', + description: 'A democracy referendum\'s execution was attempted but the preimage is missing.', + }; + } + case SubstrateEventKind.PreimageReaped: { + return { + title: 'Preimage Reaped', + description: 'A registered preimage is removed and the deposit is collected.', + }; + } + + /** + * Treasury Events + */ + case SubstrateEventKind.TreasuryProposed: { + return { + title: 'Treasury Proposed', + description: 'A treasury spend is proposed.', + }; + } + case SubstrateEventKind.TreasuryAwarded: { + return { + title: 'Treasury Awarded', + description: 'A treasury spend is awarded.', + }; + } + case SubstrateEventKind.TreasuryRejected: { + return { + title: 'Treasury Rejected', + description: 'A treasury spend is rejected.', + }; + } + + /** + * Elections Events + */ + case SubstrateEventKind.ElectionNewTerm: { + return { + title: 'New Election Term', + description: 'A new election term begins with new members.', + }; + } + case SubstrateEventKind.ElectionEmptyTerm: { + return { + title: 'Empty Election Term', + description: 'A new election term begins with no member changes.', + }; + } + case SubstrateEventKind.ElectionCandidacySubmitted: { + return { + title: 'Candidacy Submitted', + description: 'Someone submits a council candidacy.', + }; + } + case SubstrateEventKind.ElectionMemberKicked: { + return { + title: 'Member Kicked', + description: 'A member is kicked at end of term.', + }; + } + case SubstrateEventKind.ElectionMemberRenounced: { + return { + title: 'Member Renounced', + description: 'A member renounces their candidacy for the next round.', + }; + } + + /** + * Collective Events + */ + case SubstrateEventKind.CollectiveProposed: { + return { + title: 'New Collective Proposal', + description: 'A new collective proposal is introduced.', + }; + } + case SubstrateEventKind.CollectiveApproved: { + return { + title: 'Collective Proposal Approved', + description: 'A collective proposal is approved.', + }; + } + case SubstrateEventKind.CollectiveDisapproved: { + return { + title: 'Collective Proposal Disapproved', + description: 'A collective proposal is disapproved.', + }; + } + case SubstrateEventKind.CollectiveExecuted: { + return { + title: 'Collective Proposal Executed', + description: 'A collective proposal is executed.', + }; + } + case SubstrateEventKind.CollectiveMemberExecuted: { + return { + title: 'Collective Member Execution', + description: 'A collective member directly executes a proposal.', + }; + } + + /** + * Signaling Events + */ + case SubstrateEventKind.SignalingNewProposal: { + return { + title: 'New Signaling Proposal', + description: 'A new signaling proposal is introduced.', + }; + } + case SubstrateEventKind.SignalingCommitStarted: { + return { + title: 'Signaling Proposal Commit Started', + description: 'A signaling proposal\'s commit phase begins.', + }; + } + case SubstrateEventKind.SignalingVotingStarted: { + return { + title: 'Signaling Proposal Voting Started', + description: 'A signaling proposal\'s voting phase begins.', + }; + } + case SubstrateEventKind.SignalingVotingCompleted: { + return { + title: 'Signaling Proposal Voting Completed', + description: 'A signaling proposal is completed.', + }; + } + + /** + * TreasuryReward events + */ + case SubstrateEventKind.TreasuryRewardMinting: + case SubstrateEventKind.TreasuryRewardMintingV2: { + return { + title: 'Treasury Reward Minted', + description: 'A reward is added to the treasury pot.', + }; + } + + default: { + // ensure exhaustive matching -- gives ts error if missing cases + const _exhaustiveMatch: never = kind; + throw new Error('unknown event type'); + } + } +}; + +export default titlerFunc; diff --git a/shared/events/edgeware/filters/type_parser.ts b/shared/events/edgeware/filters/type_parser.ts new file mode 100644 index 00000000000..bb197d249c3 --- /dev/null +++ b/shared/events/edgeware/filters/type_parser.ts @@ -0,0 +1,89 @@ +import { Event, Extrinsic } from '@polkadot/types/interfaces'; +import { SubstrateEventKind } from '../types'; + +/** + * This is the Type Parser function, which takes a raw Substrate chain Event + * and determines which of our local event kinds it belongs to. + */ +export default function ( + versionName: string, + versionNumber: number, + section: string, + method: string, +): SubstrateEventKind | null { + // TODO: we can unify this with the enricher file: parse out the kind, and then + // marshall the rest of the types in the same place. But for now, we can leave as-is. + switch (section) { + case 'staking': + switch (method) { + case 'Slash': return SubstrateEventKind.Slash; + case 'Reward': return SubstrateEventKind.Reward; + case 'Bonded': return SubstrateEventKind.Bonded; + case 'Unbonded': return SubstrateEventKind.Unbonded; + default: return null; + } + case 'democracy': + switch (method) { + case 'Proposed': return SubstrateEventKind.DemocracyProposed; + case 'Tabled': return SubstrateEventKind.DemocracyTabled; + case 'Started': return SubstrateEventKind.DemocracyStarted; + case 'Passed': return SubstrateEventKind.DemocracyPassed; + case 'NotPassed': return SubstrateEventKind.DemocracyNotPassed; + case 'Cancelled': return SubstrateEventKind.DemocracyCancelled; + case 'Executed': return SubstrateEventKind.DemocracyExecuted; + case 'Delegated': return SubstrateEventKind.VoteDelegated; + case 'PreimageNoted': return SubstrateEventKind.PreimageNoted; + case 'PreimageUsed': return SubstrateEventKind.PreimageUsed; + case 'PreimageInvalid': return SubstrateEventKind.PreimageInvalid; + case 'PreimageMissing': return SubstrateEventKind.PreimageMissing; + case 'PreimageReaped': return SubstrateEventKind.PreimageReaped; + default: return null; + } + case 'treasury': + switch (method) { + case 'Proposed': return SubstrateEventKind.TreasuryProposed; + case 'Awarded': return SubstrateEventKind.TreasuryAwarded; + case 'Rejected': return SubstrateEventKind.TreasuryRejected; + default: return null; + } + case 'elections': + switch (method) { + case 'submitCandidacy': return SubstrateEventKind.ElectionCandidacySubmitted; + case 'NewTerm': return SubstrateEventKind.ElectionNewTerm; + case 'EmptyTerm': return SubstrateEventKind.ElectionEmptyTerm; + case 'MemberKicked': return SubstrateEventKind.ElectionMemberKicked; + case 'MemberRenounced': return SubstrateEventKind.ElectionMemberRenounced; + default: return null; + } + case 'collective': + switch (method) { + case 'Proposed': return SubstrateEventKind.CollectiveProposed; + case 'Approved': return SubstrateEventKind.CollectiveApproved; + case 'Disapproved': return SubstrateEventKind.CollectiveDisapproved; + case 'Executed': return SubstrateEventKind.CollectiveExecuted; + case 'MemberExecuted': return SubstrateEventKind.CollectiveMemberExecuted; + default: return null; + } + case 'signaling': + switch (method) { + case 'NewProposal': return SubstrateEventKind.SignalingNewProposal; + case 'CommitStarted': return SubstrateEventKind.SignalingCommitStarted; + case 'VotingStarted': return SubstrateEventKind.SignalingVotingStarted; + case 'VotingCompleted': return SubstrateEventKind.SignalingVotingCompleted; + default: return null; + } + case 'treasuryReward': + switch (method) { + case 'TreasuryMinting': { + if (versionNumber < 34) { + return SubstrateEventKind.TreasuryRewardMinting; + } else { + return SubstrateEventKind.TreasuryRewardMintingV2; + } + } + default: return null; + } + default: + return null; + } +} diff --git a/shared/events/edgeware/index.ts b/shared/events/edgeware/index.ts new file mode 100644 index 00000000000..08a1acbc011 --- /dev/null +++ b/shared/events/edgeware/index.ts @@ -0,0 +1,133 @@ +import { WsProvider, ApiPromise } from '@polkadot/api'; +import { TypeRegistry } from '@polkadot/types'; + +import * as edgewareDefinitions from 'edgeware-node-types/dist/definitions'; + +import Subscriber from './subscriber'; +import Poller from './poller'; +import Processor from './processor'; +import { SubstrateBlock } from './types'; +import { IEventHandler, IBlockSubscriber, IDisconnectedRange, CWEvent } from '../interfaces'; + +/** + * Attempts to open an API connection, retrying if it cannot be opened. + * @param url websocket endpoing to connect to, including ws[s]:// and port + * @returns a promise resolving to an ApiPromise once the connection has been established + */ +export function createApi(provider: WsProvider): ApiPromise { + const registry = new TypeRegistry(); + const edgewareTypes = Object.values(edgewareDefinitions) + .map((v) => v.default) + .reduce((res, { types }): object => ({ ...res, ...types }), {}); + return new ApiPromise({ + provider, + types: { + ...edgewareTypes, + 'voting::VoteType': 'VoteType', + 'voting::TallyType': 'TallyType', + // chain-specific overrides + Address: 'GenericAddress', + Keys: 'SessionKeys4', + StakingLedger: 'StakingLedgerTo223', + Votes: 'VotesTo230', + ReferendumInfo: 'ReferendumInfoTo239', + Weight: 'u32', + }, + registry + }); +} + +/** + * This is the main function for edgeware event handling. It constructs a connection + * to the chain, connects all event-related modules, and initializes event handling. + * + * @param url The edgeware chain endpoint to connect to. + * @param handler An event handler object for processing received events. + * @param skipCatchup If true, skip all fetching of "historical" chain data that may have been + * emitted during downtime. + * @param discoverReconnectRange A function to determine how long we were offline upon reconnection. + * @returns An active block subscriber. + */ +export default function ( + url = 'ws://localhost:9944', + handler: IEventHandler, + skipCatchup: boolean, + discoverReconnectRange?: () => Promise, +): Promise> { + const provider = new WsProvider(url); + return new Promise((resolve) => { + let subscriber; + provider.on('connected', () => { + if (subscriber) { + resolve(subscriber); + return; + } + createApi(provider).isReady.then((api) => { + subscriber = new Subscriber(api); + const poller = new Poller(api); + const processor = new Processor(api); + const processBlockFn = async (block: SubstrateBlock) => { + const events: CWEvent[] = await processor.process(block); + events.map((event) => handler.handle(event)); + }; + + const pollMissedBlocksFn = async () => { + console.log('Detected offline time, polling missed blocks...'); + // grab the cached block immediately to avoid a new block appearing before the + // server can do its thing... + const lastBlockNumber = processor.lastBlockNumber; + + // determine how large of a reconnect we dealt with + let offlineRange: IDisconnectedRange; + + // first, attempt the provided range finding method if it exists + // (this should fetch the block of the last server event from database) + if (discoverReconnectRange) { + offlineRange = await discoverReconnectRange(); + } + + // compare with default range algorithm: take last cached block in processor + // if it exists, and is more recent than the provided algorithm + // (note that on first run, we wont have a cached block/this wont do anything) + if (lastBlockNumber + && (!offlineRange || !offlineRange.startBlock || offlineRange.startBlock < lastBlockNumber)) { + offlineRange = { startBlock: lastBlockNumber }; + } + + // if we can't figure out when the last block we saw was, do nothing + // (i.e. don't try and fetch all events from block 0 onward) + if (!offlineRange || !offlineRange.startBlock) { + console.error('Unable to determine offline time range.'); + return; + } + + // poll the missed blocks for events + try { + const blocks = await poller.poll(offlineRange); + await Promise.all(blocks.map(processBlockFn)); + } catch (e) { + console.error(`Block polling failed after disconnect at block ${offlineRange.startBlock}`); + } + }; + + if (!skipCatchup) { + pollMissedBlocksFn(); + } else { + console.log('Skipping event catchup on startup!'); + } + + try { + console.log(`Subscribing to Edgeware at ${url}...`); + subscriber.subscribe(processBlockFn); + + // handle reconnects with poller + api.on('connected', pollMissedBlocksFn); + } catch (e) { + console.error(`Subscription error: ${JSON.stringify(e, null, 2)}`); + } + + resolve(subscriber); + }); + }); + }); +} diff --git a/shared/events/edgeware/poller.ts b/shared/events/edgeware/poller.ts new file mode 100644 index 00000000000..893fcdb4db2 --- /dev/null +++ b/shared/events/edgeware/poller.ts @@ -0,0 +1,61 @@ +/** + * Fetches historical events from edgeware chain. + */ +import { ApiPromise } from '@polkadot/api'; +import { Hash } from '@polkadot/types/interfaces'; + +import { IBlockPoller, IDisconnectedRange } from '../interfaces'; +import { SubstrateBlock } from './types'; + +export default class extends IBlockPoller { + /** + * Connects to chain, fetches specified blocks and passes them + * along for processing. + * + * @param startBlock first block to fetch + * @param endBlock last block to fetch, omit to fetch to latest + */ + public async poll(range: IDisconnectedRange): Promise { + // discover current block if no end block provided + if (!range.endBlock) { + const header = await this._api.rpc.chain.getHeader(); + range.endBlock = +header.number; + console.log(`Discovered endBlock: ${range.endBlock}`); + } + if ((range.endBlock - range.startBlock) <= 0) { + console.error(`End of range (${range.endBlock}) <= start (${range.startBlock})! No blocks to fetch.`); + return; + } + + // discover current version + const version = await this._api.rpc.state.getRuntimeVersion(); + const versionNumber = +version.specVersion; + const versionName = version.specName.toString(); + // TODO: on newer versions of Substrate, a "system.lastRuntimeUpgrade" query is exposed, + // which will tell us if we hit an upgrade during the polling period. But for now, we + // can assume that the chain has not been upgraded during the offline polling period + // (which for a non-archive node, is only the most recent 250 blocks anyway). + + // fetch blocks from start to end + const blockNumbers = [ ...Array(range.endBlock - range.startBlock).keys()] + .map((i) => range.startBlock + i); + console.log(`Fetching hashes for blocks: ${JSON.stringify(blockNumbers)}`); + const hashes: Hash[] = await this._api.query.system.blockHash.multi(blockNumbers); + + // remove all-0 block hashes -- those blocks have been pruned & we cannot fetch their data + const nonZeroHashes = hashes.filter((hash) => !hash.isEmpty); + console.log(`${nonZeroHashes.length} active and ${hashes.length - nonZeroHashes.length} pruned hashes fetched!`); + console.log('Fetching headers and events...'); + const blocks: SubstrateBlock[] = await Promise.all(nonZeroHashes.map(async (hash) => { + const header = await this._api.rpc.chain.getHeader(hash); + const events = await this._api.query.system.events.at(hash); + const signedBlock = await this._api.rpc.chain.getBlock(hash); + const extrinsics = signedBlock.block.extrinsics; + console.log(`Poller fetched Block: ${+header.number}`); + return { header, events, extrinsics, versionNumber, versionName }; + })); + console.log('Finished polling past blocks!'); + + return blocks; + } +} diff --git a/shared/events/edgeware/processor.ts b/shared/events/edgeware/processor.ts new file mode 100644 index 00000000000..15e90ae732c --- /dev/null +++ b/shared/events/edgeware/processor.ts @@ -0,0 +1,57 @@ +/** + * Processes edgeware blocks and emits events. + */ +import { ApiPromise } from '@polkadot/api'; +import { GenericEvent } from '@polkadot/types'; +import { Extrinsic, Event } from '@polkadot/types/interfaces'; + +import { IBlockProcessor, CWEvent } from '../interfaces'; +import { SubstrateBlock, isEvent } from './types'; +import parseEventType from './filters/type_parser'; +import enrichEvent from './filters/enricher'; + +export default class extends IBlockProcessor { + private _lastBlockNumber: number; + public get lastBlockNumber() { return this._lastBlockNumber; } + + /** + * Parse events out of an edgeware block and standardizes their format + * for processing. + * + * @param block the block received for processing + * @returns an array of processed events + */ + public async process(block: SubstrateBlock): Promise { + // cache block number if needed for disconnection purposes + const blockNumber = +block.header.number; + if (!this._lastBlockNumber || blockNumber > this._lastBlockNumber) { + this._lastBlockNumber = blockNumber; + } + + const applyFilters = async (data: Event | Extrinsic) => { + const kind = isEvent(data) + ? parseEventType(block.versionName, block.versionNumber, data.section, data.method) + : parseEventType( + block.versionName, + block.versionNumber, + data.method.sectionName, + data.method.methodName + ); + if (kind !== null) { + try { + const result = await enrichEvent(this._api, blockNumber, kind, data); + return result; + } catch (e) { + console.error(`Event enriching failed for ${kind}`); + return null; + } + } else { + return null; + } + }; + + const events = await Promise.all(block.events.map(({ event }) => applyFilters(event))); + const extrinsics = await Promise.all(block.extrinsics.map((extrinsic) => applyFilters(extrinsic))); + return [...events, ...extrinsics].filter((e) => !!e); // remove null / unwanted events + } +} diff --git a/shared/events/edgeware/subscriber.ts b/shared/events/edgeware/subscriber.ts new file mode 100644 index 00000000000..94a7f3155b8 --- /dev/null +++ b/shared/events/edgeware/subscriber.ts @@ -0,0 +1,54 @@ +/** + * Fetches events from edgeware chain in real time. + */ +import { ApiPromise } from '@polkadot/api'; +import { Header, RuntimeVersion, Extrinsic } from '@polkadot/types/interfaces'; + +import { IBlockSubscriber } from '../interfaces'; +import { SubstrateBlock } from './types'; + +export default class extends IBlockSubscriber { + private _subscription; + private _versionName: string; + private _versionNumber: number; + + /** + * Initializes subscription to chain and starts emitting events. + */ + public subscribe(cb: (block: SubstrateBlock) => any) { + // wait for version available before we start producing blocks + const runtimeVersionP = new Promise((resolve) => { + this._api.rpc.state.subscribeRuntimeVersion((version: RuntimeVersion) => { + this._versionNumber = +version.specVersion; + this._versionName = version.specName.toString(); + console.log(`Subscriber fetched runtime version for ${this._versionName}: ${this._versionNumber}`); + resolve(); + }); + }); + runtimeVersionP.then(() => { + // subscribe to events and pass to block processor + this._subscription = this._api.rpc.chain.subscribeNewHeads(async (header: Header) => { + const events = await this._api.query.system.events.at(header.hash); + const signedBlock = await this._api.rpc.chain.getBlock(header.hash); + const extrinsics: Extrinsic[] = signedBlock.block.extrinsics; + const block: SubstrateBlock = { + header, + events, + extrinsics, + versionNumber: this._versionNumber, + versionName: this._versionName, + }; + // TODO: add logging prefix output + console.log(`Subscriber fetched Block: ${+block.header.number}`); + cb(block); + }); + }); + } + + public unsubscribe() { + if (this._subscription) { + this._subscription(); + this._subscription = null; + } + } +} diff --git a/shared/events/edgeware/types.ts b/shared/events/edgeware/types.ts new file mode 100644 index 00000000000..62d558d5cde --- /dev/null +++ b/shared/events/edgeware/types.ts @@ -0,0 +1,384 @@ +import { Header, EventRecord, Extrinsic, Event } from '@polkadot/types/interfaces'; + +/** + * To implement a new form of event, add it to this enum, and add its + * JSON interface below (ensure it is stringify-able and then parse-able). + */ + +/** Special types for formatting/labeling purposes */ +export type SubstrateBalanceString = string; +export type SubstrateBigIntString = string; +export type SubstrateBlockNumber = number; +export type SubstrateAccountId = string; +export type SubstrateRuntimeVersion = number; + +/** + * Substrate lacks a block type that includes events as well, so we synthesize a type + * from the combination of headers, events, and extrinsics. + */ +export interface SubstrateBlock { + header: Header; + events: EventRecord[]; + extrinsics: Extrinsic[]; + versionNumber: number; + versionName: string; +} + +// In theory we could use a higher level type-guard here, like +// `e instanceof GenericEvent`, but that makes unit testing +// more difficult, as we need to then mock the original constructor. +export function isEvent(e: Event | Extrinsic): e is Event { + return !(e.data instanceof Uint8Array); +} + +export enum SubstrateEventKind { + Slash = 'slash', + Reward = 'reward', + Bonded = 'bonded', + Unbonded = 'unbonded', + + VoteDelegated = 'vote-delegated', + DemocracyProposed = 'democracy-proposed', + DemocracyTabled = 'democracy-tabled', + DemocracyStarted = 'democracy-started', + DemocracyPassed = 'democracy-passed', + DemocracyNotPassed = 'democracy-not-passed', + DemocracyCancelled = 'democracy-cancelled', + DemocracyExecuted = 'democracy-executed', + + PreimageNoted = 'preimage-noted', + PreimageUsed = 'preimage-used', + PreimageInvalid = 'preimage-invalid', + PreimageMissing = 'preimage-missing', + PreimageReaped = 'preimage-reaped', + + TreasuryProposed = 'treasury-proposed', + TreasuryAwarded = 'treasury-awarded', + TreasuryRejected = 'treasury-rejected', + + ElectionNewTerm = 'election-new-term', + ElectionEmptyTerm = 'election-empty-term', + ElectionCandidacySubmitted = 'election-candidacy-submitted', + ElectionMemberKicked = 'election-member-kicked', + ElectionMemberRenounced = 'election-member-renounced', + + CollectiveProposed = 'collective-proposed', + CollectiveApproved = 'collective-approved', + CollectiveDisapproved = 'collective-disapproved', + CollectiveExecuted = 'collective-executed', + CollectiveMemberExecuted = 'collective-member-executed', + // TODO: do we want to track votes as events, in collective? + + SignalingNewProposal = 'signaling-new-proposal', + SignalingCommitStarted = 'signaling-commit-started', + SignalingVotingStarted = 'signaling-voting-started', + SignalingVotingCompleted = 'signaling-voting-completed', + // TODO: do we want to track votes for signaling? + + TreasuryRewardMinting = 'treasury-reward-minting', + TreasuryRewardMintingV2 = 'treasury-reward-minting-v2', +} + +interface ISubstrateEvent { + kind: SubstrateEventKind; +} + +/** + * Staking Events + */ +export interface ISubstrateSlash extends ISubstrateEvent { + kind: SubstrateEventKind.Slash; + validator: SubstrateAccountId; + amount: SubstrateBalanceString; +} + +export interface ISubstrateReward extends ISubstrateEvent { + kind: SubstrateEventKind.Reward; + validator?: SubstrateAccountId; + amount: SubstrateBalanceString; +} + +export interface ISubstrateBonded extends ISubstrateEvent { + kind: SubstrateEventKind.Bonded; + stash: SubstrateAccountId; + amount: SubstrateBalanceString; + controller: SubstrateAccountId; +} + +export interface ISubstrateUnbonded extends ISubstrateEvent { + kind: SubstrateEventKind.Unbonded; + stash: SubstrateAccountId; + amount: SubstrateBalanceString; + controller: SubstrateAccountId; +} + +/** + * Democracy Events + */ +export interface ISubstrateVoteDelegated extends ISubstrateEvent { + kind: SubstrateEventKind.VoteDelegated; + who: SubstrateAccountId; + target: SubstrateAccountId; +} + +export interface ISubstrateDemocracyProposed extends ISubstrateEvent { + kind: SubstrateEventKind.DemocracyProposed; + proposalIndex: number; + proposalHash: string; + deposit: SubstrateBalanceString; + proposer: SubstrateAccountId; +} + +export interface ISubstrateDemocracyTabled extends ISubstrateEvent { + kind: SubstrateEventKind.DemocracyTabled; + proposalIndex: number; + // TODO: do we want to store depositors? +} + +export interface ISubstrateDemocracyStarted extends ISubstrateEvent { + kind: SubstrateEventKind.DemocracyStarted; + referendumIndex: number; + proposalHash: string; + voteThreshold: string; + endBlock: SubstrateBlockNumber; +} + +export interface ISubstrateDemocracyPassed extends ISubstrateEvent { + kind: SubstrateEventKind.DemocracyPassed; + referendumIndex: number; + dispatchBlock: SubstrateBlockNumber | null; + // TODO: worth enriching with tally? +} + +export interface ISubstrateDemocracyNotPassed extends ISubstrateEvent { + kind: SubstrateEventKind.DemocracyNotPassed; + referendumIndex: number; + // TODO: worth enriching with tally? +} + +export interface ISubstrateDemocracyCancelled extends ISubstrateEvent { + kind: SubstrateEventKind.DemocracyCancelled; + referendumIndex: number; +} + +export interface ISubstrateDemocracyExecuted extends ISubstrateEvent { + kind: SubstrateEventKind.DemocracyExecuted; + referendumIndex: number; + executionOk: boolean; +} + +/** + * Preimage Events + * TODO: do we want to track depositors and deposit amounts? + */ +export interface ISubstratePreimageNoted extends ISubstrateEvent { + kind: SubstrateEventKind.PreimageNoted; + proposalHash: string; + noter: SubstrateAccountId; +} + +export interface ISubstratePreimageUsed extends ISubstrateEvent { + kind: SubstrateEventKind.PreimageUsed; + proposalHash: string; + noter: SubstrateAccountId; +} + +export interface ISubstratePreimageInvalid extends ISubstrateEvent { + kind: SubstrateEventKind.PreimageInvalid; + proposalHash: string; + referendumIndex: number; +} + +export interface ISubstratePreimageMissing extends ISubstrateEvent { + kind: SubstrateEventKind.PreimageMissing; + proposalHash: string; + referendumIndex: number; +} + +export interface ISubstratePreimageReaped extends ISubstrateEvent { + kind: SubstrateEventKind.PreimageReaped; + proposalHash: string; + noter: SubstrateAccountId; + reaper: SubstrateAccountId; +} + +/** + * Treasury Events + */ +export interface ISubstrateTreasuryProposed extends ISubstrateEvent { + kind: SubstrateEventKind.TreasuryProposed; + proposalIndex: number; + proposer: SubstrateAccountId; + value: SubstrateBalanceString; + beneficiary: SubstrateAccountId; + // can also fetch bond if needed +} + +export interface ISubstrateTreasuryAwarded extends ISubstrateEvent { + kind: SubstrateEventKind.TreasuryAwarded; + proposalIndex: number; + value: SubstrateBalanceString; + beneficiary: SubstrateAccountId; +} + +export interface ISubstrateTreasuryRejected extends ISubstrateEvent { + kind: SubstrateEventKind.TreasuryRejected; + proposalIndex: number; + // can also fetch slashed bond value if needed + // cannot fetch other data because proposal data disappears on rejection +} + +/** + * Elections Events + */ +export interface ISubstrateElectionNewTerm extends ISubstrateEvent { + kind: SubstrateEventKind.ElectionNewTerm; + newMembers: SubstrateAccountId[]; +} + +export interface ISubstrateElectionEmptyTerm extends ISubstrateEvent { + kind: SubstrateEventKind.ElectionEmptyTerm; +} + +export interface ISubstrateCandidacySubmitted extends ISubstrateEvent { + kind: SubstrateEventKind.ElectionCandidacySubmitted; + candidate: SubstrateAccountId; +} + +export interface ISubstrateElectionMemberKicked extends ISubstrateEvent { + kind: SubstrateEventKind.ElectionMemberKicked; + who: SubstrateAccountId; +} + +export interface ISubstrateElectionMemberRenounced extends ISubstrateEvent { + kind: SubstrateEventKind.ElectionMemberRenounced; + who: SubstrateAccountId; +} + +/** + * Collective Events + */ +export interface ISubstrateCollectiveProposed extends ISubstrateEvent { + kind: SubstrateEventKind.CollectiveProposed; + proposer: SubstrateAccountId; + proposalIndex: number; + proposalHash: string; + threshold: number; +} + +export interface ISubstrateCollectiveApproved extends ISubstrateEvent { + kind: SubstrateEventKind.CollectiveApproved; + proposalHash: string; + proposalIndex: number; + threshold: number; + ayes: SubstrateAccountId[]; + nays: SubstrateAccountId[]; +} + +export interface ISubstrateCollectiveDisapproved extends ISubstrateEvent { + kind: SubstrateEventKind.CollectiveDisapproved; + proposalHash: string; + proposalIndex: number; + threshold: number; + ayes: SubstrateAccountId[]; + nays: SubstrateAccountId[]; +} + +export interface ISubstrateCollectiveExecuted extends ISubstrateEvent { + kind: SubstrateEventKind.CollectiveExecuted; + proposalHash: string; + executionOk: boolean; +} + +export interface ISubstrateCollectiveMemberExecuted extends ISubstrateEvent { + kind: SubstrateEventKind.CollectiveMemberExecuted; + proposalHash: string; + executionOk: boolean; +} + +/** + * Signaling Events + */ +export interface ISubstrateSignalingNewProposal extends ISubstrateEvent { + kind: SubstrateEventKind.SignalingNewProposal; + proposer: SubstrateAccountId; + proposalHash: string; + voteId: SubstrateBigIntString; +} + +export interface ISubstrateSignalingCommitStarted extends ISubstrateEvent { + kind: SubstrateEventKind.SignalingCommitStarted; + proposalHash: string; + voteId: SubstrateBigIntString; + endBlock: number; +} + +export interface ISubstrateSignalingVotingStarted extends ISubstrateEvent { + kind: SubstrateEventKind.SignalingVotingStarted; + proposalHash: string; + voteId: SubstrateBigIntString; + endBlock: number; +} + +export interface ISubstrateSignalingVotingCompleted extends ISubstrateEvent { + kind: SubstrateEventKind.SignalingVotingCompleted; + proposalHash: string; + voteId: SubstrateBigIntString; + // TODO: worth enriching with tally? +} + +/** + * TreasuryReward events + */ +export interface ISubstrateTreasuryRewardMinting extends ISubstrateEvent { + kind: SubstrateEventKind.TreasuryRewardMinting; + pot: SubstrateBalanceString; + reward: SubstrateBalanceString; +} +export interface ISubstrateTreasuryRewardMintingV2 extends ISubstrateEvent { + kind: SubstrateEventKind.TreasuryRewardMintingV2; + pot: SubstrateBalanceString; + potAddress: SubstrateAccountId; +} + +export type ISubstrateEventData = + ISubstrateSlash + | ISubstrateReward + | ISubstrateBonded + | ISubstrateUnbonded + | ISubstrateVoteDelegated + | ISubstrateDemocracyProposed + | ISubstrateDemocracyTabled + | ISubstrateDemocracyStarted + | ISubstrateDemocracyPassed + | ISubstrateDemocracyNotPassed + | ISubstrateDemocracyCancelled + | ISubstrateDemocracyExecuted + | ISubstratePreimageNoted + | ISubstratePreimageUsed + | ISubstratePreimageInvalid + | ISubstratePreimageMissing + | ISubstratePreimageReaped + | ISubstrateTreasuryProposed + | ISubstrateTreasuryAwarded + | ISubstrateTreasuryRejected + | ISubstrateElectionNewTerm + | ISubstrateElectionEmptyTerm + | ISubstrateCandidacySubmitted + | ISubstrateElectionMemberKicked + | ISubstrateElectionMemberRenounced + | ISubstrateCollectiveProposed + | ISubstrateCollectiveApproved + | ISubstrateCollectiveDisapproved + | ISubstrateCollectiveExecuted + | ISubstrateCollectiveMemberExecuted + | ISubstrateSignalingNewProposal + | ISubstrateSignalingCommitStarted + | ISubstrateSignalingVotingStarted + | ISubstrateSignalingVotingCompleted + | ISubstrateTreasuryRewardMinting + | ISubstrateTreasuryRewardMintingV2 +// eslint-disable-next-line semi-style +; + +export const SubstrateEventKinds: SubstrateEventKind[] = Object.values(SubstrateEventKind); diff --git a/shared/events/interfaces.ts b/shared/events/interfaces.ts new file mode 100644 index 00000000000..55e0dfc7648 --- /dev/null +++ b/shared/events/interfaces.ts @@ -0,0 +1,86 @@ +/** + * Defines general interfaces for chain event fetching and processing. + */ + +import { ISubstrateEventData, SubstrateEventKind } from './edgeware/types'; + +// add other events here as union types +export type IChainEventData = ISubstrateEventData; +export type IChainEventKind = SubstrateEventKind; +export const EventSupportingChains = ['edgeware', 'edgeware-local']; + +export interface CWEvent { + blockNumber: number; + includeAddresses?: string[]; + excludeAddresses?: string[]; + + data: IChainEventData; +} + +// handles individual events by sending them off to storage/notifying +export abstract class IEventHandler { + // throws on error + public abstract handle(event: CWEvent): Promise; +} + +// parses events out of blocks into a standard format and +// passes them through to the handler +export abstract class IBlockProcessor { + constructor( + protected _api: Api, + ) { } + + // throws on error + public abstract async process(block: Block): Promise; +} + +// fetches blocks from chain in real-time via subscription for processing +export abstract class IBlockSubscriber { + constructor( + protected _api: Api, + ) { } + + // throws on error + public abstract subscribe(cb: (block: Block) => any): void; + + public abstract unsubscribe(): void; +} + +export interface IDisconnectedRange { + startBlock: number; + endBlock?: number; +} + +// fetches historical blocks from chain for processing +export abstract class IBlockPoller { + constructor( + protected _api: Api, + ) { } + + // throws on error + public abstract async poll(range: IDisconnectedRange): Promise; +} + +// a set of labels used to display notifications +export interface IEventLabel { + heading: string; + label: string; + linkUrl?: string; +} + +// a function that prepares chain data for user display +export type LabelerFilter = ( + blockNumber: number, + chainId: string, + data: IChainEventData, + ...formatters +) => IEventLabel; + +export interface IEventTitle { + title: string; + description: string; +} + +export type TitlerFilter = ( + kind: IChainEventKind, +) => IEventTitle; diff --git a/shared/types.ts b/shared/types.ts index f253845b68e..b922d09d744 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -11,6 +11,7 @@ export const NotificationCategories = { NewReaction: 'new-reaction', ThreadEdit: 'thread-edit', CommentEdit: 'comment-edit', + ChainEvent: 'chain-event', }; export enum ProposalType { diff --git a/test/mocha.opts b/test/mocha.opts index 92849146f90..8ed07c61fe3 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -3,5 +3,4 @@ --require tsconfig-paths/register --require @babel/register --require source-map-support/register - --timeout 10000 - --require mocha-steps \ No newline at end of file + --timeout 10000 \ No newline at end of file diff --git a/test/unit/events/edgeware/enricher.spec.ts b/test/unit/events/edgeware/enricher.spec.ts new file mode 100644 index 00000000000..a18a3e019d9 --- /dev/null +++ b/test/unit/events/edgeware/enricher.spec.ts @@ -0,0 +1,629 @@ +import chai from 'chai'; +import { + AccountId, PropIndex, Hash, ReferendumInfoTo239, BlockNumber, + ReferendumIndex, TreasuryProposal, Votes, Event, Extrinsic +} from '@polkadot/types/interfaces'; +import { DeriveDispatch } from '@polkadot/api-derive/types'; +import { Vec, bool } from '@polkadot/types'; +import { ITuple, TypeDef } from '@polkadot/types/types'; +import { ProposalRecord } from 'edgeware-node-types/dist'; + +import EdgewareEnricherFunc from '../../../../shared/events/edgeware/filters/enricher'; +import { constructFakeApi, constructOption } from './testUtil'; +import { SubstrateEventKind } from '../../../../shared/events/edgeware/types'; + +const { assert } = chai; + +const blockNumber = 10; +const api = constructFakeApi({ + bonded: async (stash) => stash !== 'alice-stash' + ? constructOption() + : constructOption('alice' as unknown as AccountId), + publicProps: async () => [ + [ 1, 'hash1', 'charlie' ], + [ 2, 'hash2', 'dave' ] + ] as unknown as Vec>, + referendumInfoOf: async (idx) => +idx !== 1 + ? constructOption() + : constructOption({ + end: 20, + hash: 'hash', + threshold: 'Supermajorityapproval', + delay: 10, + } as unknown as ReferendumInfoTo239), + dispatchQueue: async () => [ + { index: 1, imageHash: 'hash1', at: 20 }, + { index: 2, imageHash: 'hash2', at: 30 }, + ] as unknown as DeriveDispatch[], + proposals: (idx) => +idx !== 1 + ? constructOption() + : constructOption({ + proposer: 'alice', + value: 1000, + beneficiary: 'bob', + } as unknown as TreasuryProposal), + voting: (hash) => hash.toString() !== 'hash' + ? constructOption() + : constructOption({ + index: 1, + threshold: 3, + ayes: [ 'alice', 'bob' ], + nays: [ 'charlie', 'dave' ], + end: 100, + } as unknown as Votes), + proposalOf: (hash) => hash.toString() !== 'hash' + ? constructOption() + : constructOption({ + index: 1, + author: 'alice', + stage: 'Voting', + transition_time: 20, + title: 'test proposal', + contents: 'this is a test proposal', + vote_id: 101, + } as unknown as ProposalRecord), +}); + +class FakeEventData extends Array { + public readonly typeDef: TypeDef[]; + constructor(typeDef: string[], ...values) { + super(...values); + this.typeDef = typeDef.map((type) => ({ type })) as TypeDef[]; + } +} + +const constructEvent = (data: any[], typeDef: string[] = []): Event => { + return { + data: new FakeEventData(typeDef, ...data), + } as Event; +}; + +const constructExtrinsic = (signer: string, args: any[] = []): Extrinsic => { + return { + signer, + args, + data: new Uint8Array(), + } as unknown as Extrinsic; +}; + +const constructBool = (b: boolean): bool => { + return { isTrue: b === true, isFalse: b === false, isEmpty: false } as bool; +}; + +/* eslint-disable: dot-notation */ +describe('Edgeware Event Enricher Filter Tests', () => { + /** staking events */ + it('should enrich edgeware/old reward event', async () => { + const kind = SubstrateEventKind.Reward; + const event = constructEvent([ 10000, 5 ], [ 'Balance', 'Balance' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + amount: '10000', + } + }); + }); + it('should enrich new reward event', async () => { + const kind = SubstrateEventKind.Reward; + const event = constructEvent([ 'Alice', 10000 ], [ 'AccountId', 'Balance' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + includeAddresses: [ 'Alice' ], + data: { + kind, + validator: 'Alice', + amount: '10000', + } + }); + }); + it('should enrich slash event', async () => { + const kind = SubstrateEventKind.Slash; + const event = constructEvent([ 'Alice', 10000 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + includeAddresses: [ 'Alice' ], + data: { + kind, + validator: 'Alice', + amount: '10000', + } + }); + }); + it('should enrich bonded event', async () => { + const kind = SubstrateEventKind.Bonded; + const event = constructEvent([ 'alice-stash', 10000 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + includeAddresses: [ 'alice-stash' ], + data: { + kind, + stash: 'alice-stash', + amount: '10000', + controller: 'alice', + } + }); + }); + it('should enrich unbonded event', async () => { + const kind = SubstrateEventKind.Unbonded; + const event = constructEvent([ 'alice-stash', 10000 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + includeAddresses: [ 'alice-stash' ], + data: { + kind, + stash: 'alice-stash', + amount: '10000', + controller: 'alice', + } + }); + }); + + /** democracy events */ + it('should enrich vote-delegated event', async () => { + const kind = SubstrateEventKind.VoteDelegated; + const event = constructEvent([ 'delegator', 'target' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + includeAddresses: [ 'target' ], + data: { + kind, + who: 'delegator', + target: 'target', + } + }); + }); + it('should enrich democracy-proposed event', async () => { + const kind = SubstrateEventKind.DemocracyProposed; + const event = constructEvent([ '1', 1000 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + excludeAddresses: [ 'charlie' ], + data: { + kind, + proposalIndex: 1, + proposalHash: 'hash1', + deposit: '1000', + proposer: 'charlie', + } + }); + }); + it('should enrich democracy-tabled event', async () => { + const kind = SubstrateEventKind.DemocracyTabled; + const event = constructEvent([ '1', 1000 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalIndex: 1, + } + }); + }); + it('should enrich democracy-started event', async () => { + const kind = SubstrateEventKind.DemocracyStarted; + const event = constructEvent([ '1', 'Supermajorityapproval' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + referendumIndex: 1, + proposalHash: 'hash', + voteThreshold: 'Supermajorityapproval', + endBlock: 20, + } + }); + }); + it('should enrich democracy-passed event', async () => { + const kind = SubstrateEventKind.DemocracyPassed; + const event = constructEvent([ '1' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + referendumIndex: 1, + dispatchBlock: 20, + } + }); + }); + it('should enrich democracy-not-passed event', async () => { + const kind = SubstrateEventKind.DemocracyNotPassed; + const event = constructEvent([ '1' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + referendumIndex: 1, + } + }); + }); + it('should enrich democracy-cancelled event', async () => { + const kind = SubstrateEventKind.DemocracyCancelled; + const event = constructEvent([ '1' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + referendumIndex: 1, + } + }); + }); + it('should enrich democracy-executed event', async () => { + const kind = SubstrateEventKind.DemocracyExecuted; + const event = constructEvent([ '1', constructBool(false) ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + referendumIndex: 1, + executionOk: false, + } + }); + }); + + /** preimage events */ + it('should enrich preimage-noted event', async () => { + const kind = SubstrateEventKind.PreimageNoted; + const event = constructEvent([ 'hash', 'alice', 100 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + excludeAddresses: [ 'alice' ], + data: { + kind, + proposalHash: 'hash', + noter: 'alice', + } + }); + }); + it('should enrich preimage-used event', async () => { + const kind = SubstrateEventKind.PreimageUsed; + const event = constructEvent([ 'hash', 'alice', 100 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + noter: 'alice', + } + }); + }); + it('should enrich preimage-invalid event', async () => { + const kind = SubstrateEventKind.PreimageInvalid; + const event = constructEvent([ 'hash', '1' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + referendumIndex: 1, + } + }); + }); + it('should enrich preimage-missing event', async () => { + const kind = SubstrateEventKind.PreimageMissing; + const event = constructEvent([ 'hash', '1' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + referendumIndex: 1, + } + }); + }); + it('should enrich preimage-reaped event', async () => { + const kind = SubstrateEventKind.PreimageReaped; + const event = constructEvent([ 'hash', 'alice', 100, 'bob' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + excludeAddresses: [ 'bob' ], + data: { + kind, + proposalHash: 'hash', + noter: 'alice', + reaper: 'bob', + } + }); + }); + + /** treasury events */ + it('should enrich treasury-proposed event', async () => { + const kind = SubstrateEventKind.TreasuryProposed; + const event = constructEvent([ '1' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + excludeAddresses: [ 'alice' ], + data: { + kind, + proposalIndex: 1, + proposer: 'alice', + value: '1000', + beneficiary: 'bob', + } + }); + }); + it('should enrich treasury-awarded event', async () => { + const kind = SubstrateEventKind.TreasuryAwarded; + const event = constructEvent([ '1', 1000, 'bob' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalIndex: 1, + value: '1000', + beneficiary: 'bob', + } + }); + }); + it('should enrich treasury-rejected event', async () => { + const kind = SubstrateEventKind.TreasuryRejected; + const event = constructEvent([ '1', 100 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalIndex: 1, + } + }); + }); + + /** elections events */ + it('should enrich election-new-term event', async () => { + const kind = SubstrateEventKind.ElectionNewTerm; + const event = constructEvent([ [ [ 'alice', 10 ], [ 'bob', 20] ] ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + newMembers: [ 'alice', 'bob' ], + } + }); + }); + it('should enrich election-empty-term event', async () => { + const kind = SubstrateEventKind.ElectionEmptyTerm; + const event = constructEvent([ ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + } + }); + }); + it('should enrich election-candidacy-submitted event', async () => { + const kind = SubstrateEventKind.ElectionCandidacySubmitted; + const extrinsic = constructExtrinsic('alice'); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, extrinsic); + assert.deepEqual(result, { + blockNumber, + excludeAddresses: [ 'alice' ], + data: { + kind, + candidate: 'alice', + } + }); + }); + it('should enrich election-member-kicked event', async () => { + const kind = SubstrateEventKind.ElectionMemberKicked; + const event = constructEvent([ 'alice' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + who: 'alice', + } + }); + }); + it('should enrich election-member-renounced event', async () => { + const kind = SubstrateEventKind.ElectionMemberRenounced; + const event = constructEvent([ 'alice' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + who: 'alice', + } + }); + }); + + /** collective events */ + it('should enrich collective-proposed event', async () => { + const kind = SubstrateEventKind.CollectiveProposed; + const event = constructEvent([ 'alice', '1', 'hash', '3' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + excludeAddresses: [ 'alice' ], + data: { + kind, + proposer: 'alice', + proposalIndex: 1, + proposalHash: 'hash', + threshold: 3, + } + }); + }); + it('should enrich collective-approved event', async () => { + const kind = SubstrateEventKind.CollectiveApproved; + const event = constructEvent([ 'hash' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + proposalIndex: 1, + threshold: 3, + ayes: [ 'alice', 'bob' ], + nays: [ 'charlie', 'dave' ], + } + }); + }); + it('should enrich collective-disapproved event', async () => { + const kind = SubstrateEventKind.CollectiveDisapproved; + const event = constructEvent([ 'hash' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + proposalIndex: 1, + threshold: 3, + ayes: [ 'alice', 'bob' ], + nays: [ 'charlie', 'dave' ], + } + }); + }); + it('should enrich collective-executed event', async () => { + const kind = SubstrateEventKind.CollectiveExecuted; + const event = constructEvent([ 'hash', constructBool(true) ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + executionOk: true, + } + }); + }); + it('should enrich collective-member-executed event', async () => { + const kind = SubstrateEventKind.CollectiveExecuted; + const event = constructEvent([ 'hash', constructBool(false) ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + executionOk: false, + } + }); + }); + + /** signaling events */ + it('should enrich signaling-new-proposal event', async () => { + const kind = SubstrateEventKind.SignalingNewProposal; + const event = constructEvent([ 'alice', 'hash' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + excludeAddresses: [ 'alice' ], + data: { + kind, + proposer: 'alice', + proposalHash: 'hash', + voteId: '101', + } + }); + }); + it('should enrich signaling-commit-started event', async () => { + const kind = SubstrateEventKind.SignalingCommitStarted; + const event = constructEvent([ 'hash', '101', '20' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + voteId: '101', + endBlock: 20, + } + }); + }); + it('should enrich signaling-voting-started event', async () => { + const kind = SubstrateEventKind.SignalingVotingStarted; + const event = constructEvent([ 'hash', '101', '20' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + voteId: '101', + endBlock: 20, + } + }); + }); + it('should enrich signaling-voting-completed event', async () => { + const kind = SubstrateEventKind.SignalingVotingCompleted; + const event = constructEvent([ 'hash', '101' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + proposalHash: 'hash', + voteId: '101', + } + }); + }); + + /** TreasuryReward events */ + it('should enrich treasury-reward-minted-v1 event', async () => { + const kind = SubstrateEventKind.TreasuryRewardMinting; + const event = constructEvent([ 1000, 100, 10 ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + pot: '1000', + reward: '100', + } + }); + }); + it('should enrich treasury-reward-minted-v2 event', async () => { + const kind = SubstrateEventKind.TreasuryRewardMintingV2; + const event = constructEvent([ 1000, 10, 'pot' ]); + const result = await EdgewareEnricherFunc(api, blockNumber, kind, event); + assert.deepEqual(result, { + blockNumber, + data: { + kind, + pot: '1000', + potAddress: 'pot', + } + }); + }); + + /** other */ + it('should not enrich invalid event', (done) => { + const kind = 'invalid-event' as SubstrateEventKind; + const event = constructEvent([ ]); + EdgewareEnricherFunc(api, blockNumber, kind, event) + .then((v) => done(new Error('should not permit invalid event'))) + .catch(() => done()); + }); + it('should not enrich with invalid API query', (done) => { + const kind = SubstrateEventKind.Bonded; + const event = constructEvent([ 'alice-not-stash', 10000 ]); + EdgewareEnricherFunc(api, blockNumber, kind, event) + .then((v) => done(new Error('should not permit invalid API result'))) + .catch(() => done()); + }); +}); diff --git a/test/unit/events/edgeware/eventHandler.spec.ts b/test/unit/events/edgeware/eventHandler.spec.ts new file mode 100644 index 00000000000..ad9e0804a9c --- /dev/null +++ b/test/unit/events/edgeware/eventHandler.spec.ts @@ -0,0 +1,230 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable dot-notation */ +import chai from 'chai'; +import chaiHttp from 'chai-http'; +import 'chai/register-should'; + +import { resetDatabase, closeServer } from '../../../../server-test'; +import models from '../../../../server/database'; +import { NotificationCategories } from '../../../../shared/types'; +import EdgewareEventHandler from '../../../../server/eventHandlers/edgeware'; +import { CWEvent } from '../../../../shared/events/interfaces'; +import { SubstrateEventKind } from '../../../../shared/events/edgeware/types'; + +chai.use(chaiHttp); +const { assert } = chai; + +const setupUserAndEventSubscriptions = async (email, address, chain) => { + const user = await models['User'].create({ + email, + emailVerified: true, + isAdmin: false, + lastVisited: '{}', + }); + + await models['Address'].create({ + user_id: user.id, + address, + chain, + selected: true, + verification_token: 'PLACEHOLDER', + verification_token_expires: null, + verified: new Date(), + created_at: new Date(), + updated_at: new Date(), + }); + + await models['Subscription'].create({ + subscriber_id: user.id, + category_id: NotificationCategories.ChainEvent, + object_id: 'edgeware-democracy-started', + is_active: true, + }); + + await models['Subscription'].create({ + subscriber_id: user.id, + category_id: NotificationCategories.ChainEvent, + object_id: 'edgeware-slash', + is_active: true, + }); +}; + +describe('Event Handler Tests', () => { + before('reset database', async () => { + await resetDatabase(); + + await setupUserAndEventSubscriptions( + 'alice@gmail.com', + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + 'edgeware', + ); + + await setupUserAndEventSubscriptions( + 'bob@gmail.com', + '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty', + 'edgeware', + ); + }); + + after('close database', async () => { + await closeServer(); + }); + + it('should create chain event and emit notification', async () => { + // setup + const event: CWEvent = { + blockNumber: 10, + data: { + kind: SubstrateEventKind.DemocracyStarted, + referendumIndex: 0, + endBlock: 100, + proposalHash: 'hash', + voteThreshold: 'Supermajorityapproval', + } + }; + + const eventHandler = new EdgewareEventHandler(models, null, 'edgeware'); + + // process event + await eventHandler.handle(event); + + // expect results + const chainEvents = await models['ChainEvent'].findAll({ + where: { + chain_event_type_id: 'edgeware-democracy-started', + block_number: 10, + } + }); + assert.lengthOf(chainEvents, 1); + assert.deepEqual(chainEvents[0].event_data, event.data); + + const notifications = await models['Notification'].findAll({ + where: { + chain_event_id: chainEvents[0].id, + }, + include: [{ + model: models['Subscription'], + include: [{ + model: models['User'], + }] + }] + }); + const userEmails = notifications.map((n) => n.Subscription.User.email); + assert.sameMembers(userEmails, ['alice@gmail.com', 'bob@gmail.com']); + }); + + it('should only include specified users if includeAddresses present', async () => { + // setup + const event: CWEvent = { + blockNumber: 11, + includeAddresses: ['5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'], + data: { + kind: SubstrateEventKind.Slash, + validator: '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty', + amount: '10000', + } + }; + + const eventHandler = new EdgewareEventHandler(models, null, 'edgeware'); + + // process event + await eventHandler.handle(event); + + // expect results + const chainEvents = await models['ChainEvent'].findAll({ + where: { + chain_event_type_id: 'edgeware-slash', + block_number: 11, + } + }); + assert.lengthOf(chainEvents, 1); + assert.deepEqual(chainEvents[0].event_data, event.data); + + const notifications = await models['Notification'].findAll({ + where: { + chain_event_id: chainEvents[0].id, + }, + include: [{ + model: models['Subscription'], + include: [{ + model: models['User'], + }] + }] + }); + + // should only notify bob + const userEmails = notifications.map((n) => n.Subscription.User.email); + assert.sameMembers(userEmails, ['bob@gmail.com']); + }); + + it('should only exclude specified users if excludeAddresses present', async () => { + // setup + const event: CWEvent = { + blockNumber: 12, + excludeAddresses: ['5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'], + data: { + kind: SubstrateEventKind.DemocracyStarted, + referendumIndex: 1, + proposalHash: 'hash', + voteThreshold: 'Supermajorityapproval', + endBlock: 101, + } + }; + + const eventHandler = new EdgewareEventHandler(models, null, 'edgeware'); + + // process event + await eventHandler.handle(event); + + // expect results + const chainEvents = await models['ChainEvent'].findAll({ + where: { + chain_event_type_id: 'edgeware-democracy-started', + block_number: 12, + } + }); + assert.lengthOf(chainEvents, 1); + assert.deepEqual(chainEvents[0].event_data, event.data); + + const notifications = await models['Notification'].findAll({ + where: { + chain_event_id: chainEvents[0].id, + }, + include: [{ + model: models['Subscription'], + include: [{ + model: models['User'], + }] + }] + }); + + // should only notify alice, excluding bob + const userEmails = notifications.map((n) => n.Subscription.User.email); + assert.sameMembers(userEmails, ['alice@gmail.com']); + }); + + it('should not create chain event for unknown event type', async () => { + const event = { + blockNumber: 13, + + data: { + kind: 'democracy-exploded', + whoops: true, + } + }; + + const eventHandler = new EdgewareEventHandler(models, null, 'edgeware'); + + // process event + await eventHandler.handle(event as unknown as CWEvent); + + // confirm no event emitted + const chainEvents = await models['ChainEvent'].findAll({ + where: { + chain_event_type_id: 'edgeware-democracy-exploded', + block_number: 12, + } + }); + assert.lengthOf(chainEvents, 0); + }); +}); diff --git a/test/unit/events/edgeware/poller.spec.ts b/test/unit/events/edgeware/poller.spec.ts new file mode 100644 index 00000000000..85242fdf0f2 --- /dev/null +++ b/test/unit/events/edgeware/poller.spec.ts @@ -0,0 +1,144 @@ +import chai from 'chai'; +import { Hash, EventRecord, Header, RuntimeVersion } from '@polkadot/types/interfaces'; + +import { constructFakeApi } from './testUtil'; +import Poller from '../../../../shared/events/edgeware/poller'; + +const { assert } = chai; + +// we need a number that implements "isEmpty" when 0, to align with +// the Substrate Hash's interface +class IMockHash extends Number { + get isEmpty(): boolean { + return this.valueOf() === 0; + } +} + +const hashNums: number[] = [...Array(10).keys()].map((i) => i < 5 ? 0 : i); +const hashes = hashNums.map((n) => new IMockHash(n)) as unknown as Hash[]; +const headers: Header[] = hashNums.map((hash) => { + if (hash === 0) { + return undefined; + } else { + return { + parentHash: (hash - 1), + number: 100 + hash, + hash, + } as unknown as Header; + } +}); + +const events = { + 6: [{ event: { data: [1] } }] as unknown as EventRecord[], + 8: [{ event: { data: [2] } }, { event: { data: [3, 4] } }] as unknown as EventRecord[], +}; + +const getMockApi = () => { + return constructFakeApi({ + getHeader: (hash?: Hash) => { + if (hash === undefined) { + hash = hashes[hashes.length - 1]; + } + return headers[hash as unknown as number]; + }, + 'events.at': (hash: Hash) => { + return events[hash as unknown as number] || []; + }, + 'blockHash.multi': (blockNumbers: number[]) => { + return blockNumbers.map((n) => hashes[n - 100]); + }, + getBlock: (hash) => { + return { + block: { + extrinsics: [], + } + }; + }, + getRuntimeVersion: () => { + return { + specVersion: 10, + specName: 'edgeware', + } as unknown as RuntimeVersion; + } + }); +}; + +/* eslint-disable: dot-notation */ +describe('Edgeware Event Poller Tests', () => { + it('should return block data', async () => { + // setup mock data + const api = getMockApi(); + + // setup test class + const poller = new Poller(api); + + // run test + const blocks = await poller.poll({ startBlock: 105, endBlock: 108 }); + assert.lengthOf(blocks, 3); + assert.equal(+blocks[0].header.number, 105); + assert.deepEqual(blocks[0].events, []); + assert.equal(blocks[0].versionNumber, 10); + assert.equal(blocks[0].versionName, 'edgeware'); + assert.equal(+blocks[1].header.number, 106); + assert.deepEqual(blocks[1].events, events[6]); + assert.equal(blocks[1].versionNumber, 10); + assert.equal(blocks[1].versionName, 'edgeware'); + assert.equal(+blocks[2].header.number, 107); + assert.deepEqual(blocks[2].events, []); + assert.equal(blocks[2].versionNumber, 10); + assert.equal(blocks[2].versionName, 'edgeware'); + }); + + it('should skip zeroed hashes', async () => { + // setup mock data + const api = getMockApi(); + + // setup test class + const poller = new Poller(api); + + // run test + const blocks = await poller.poll({ startBlock: 101, endBlock: 106 }); + assert.lengthOf(blocks, 1); + assert.equal(+blocks[0].header.number, 105); + assert.deepEqual(blocks[0].events, []); + assert.equal(blocks[0].versionNumber, 10); + assert.equal(blocks[0].versionName, 'edgeware'); + }); + + + it('should derive endblock from header', async () => { + // setup mock data + const api = getMockApi(); + + // setup test class + const poller = new Poller(api); + + // run test + const blocks = await poller.poll({ startBlock: 107 }); + assert.lengthOf(blocks, 2); + assert.equal(+blocks[0].header.number, 107); + assert.deepEqual(blocks[0].events, []); + assert.equal(blocks[0].versionNumber, 10); + assert.equal(blocks[0].versionName, 'edgeware'); + assert.equal(+blocks[1].header.number, 108); + assert.deepEqual(blocks[1].events, events[8]); + assert.equal(blocks[1].versionNumber, 10); + assert.equal(blocks[1].versionName, 'edgeware'); + }); + + it('should not accept invalid start/end blocks', async () => { + // setup mock data + const api = getMockApi(); + + // setup test class + const poller = new Poller(api); + + assert.isUndefined(await poller.poll({ + startBlock: 111, + })); + assert.isUndefined(await poller.poll({ + startBlock: 100, + endBlock: 99, + })); + }); +}); diff --git a/test/unit/events/edgeware/processor.spec.ts b/test/unit/events/edgeware/processor.spec.ts new file mode 100644 index 00000000000..af3ae48060b --- /dev/null +++ b/test/unit/events/edgeware/processor.spec.ts @@ -0,0 +1,264 @@ +import chai from 'chai'; +import { Header, EventRecord, Extrinsic } from '@polkadot/types/interfaces'; + +import Processor from '../../../../shared/events/edgeware/processor'; +import { SubstrateEventKind, ISubstrateSlash } from '../../../../shared/events/edgeware/types'; +import { constructFakeApi } from './testUtil'; + +const { assert } = chai; + +interface IFakeEvent { + section: string; + method: string; + data: any; +} + +const constructFakeBlock = (blockNumber: number, events: IFakeEvent[], extrinsics = []) => { + return { + header: { + hash: blockNumber, + number: blockNumber, + } as unknown as Header, + events: events.map( + (event) => ({ event } as unknown as EventRecord) + ), + versionNumber: 10, + versionName: 'edgeware', + extrinsics: extrinsics as Extrinsic[], + }; +}; + +describe('Edgeware Event Processor Tests', () => { + it('should process blocks into events', (done) => { + // setup fake data + const fakeEvents: IFakeEvent[] = [ + { + section: 'staking', + method: 'Slash', + data: [ 'Alice', '10000' ], + }, + { + section: 'democracy', + method: 'Proposed', + data: [ '4', '100000' ], + }, + { + section: 'democracy', + method: 'Started', + data: [ '5', 'Supermajorityapproval' ], + }, + ]; + + const fakeExtrinsics = [ + { + method: { + sectionName: 'elections', + methodName: 'submitCandidacy', + args: [], + }, + signer: 'Alice', + data: new Uint8Array(), + } + ]; + + const fakeBlocks = [ + constructFakeBlock(1, fakeEvents.slice(0, 2)), + constructFakeBlock(2, fakeEvents.slice(2, 3), fakeExtrinsics), + ]; + + const api = constructFakeApi({ + publicProps: async () => { + return [ [ ], [ 4, 'hash', 'Alice' ] ]; + }, + referendumInfoOf: async (idx) => { + if (+idx === 5) { + return { + isSome: true, + isNone: false, + unwrap: () => { + return { + end: '123', + hash: 'hash', + }; + }, + }; + } else { + throw new Error('bad referendum idx'); + } + }, + }); + + // run test + const processor = new Processor(api); + Promise.all( + fakeBlocks.map((block) => processor.process(block)) + ).then((results) => { + assert.equal(processor.lastBlockNumber, 2); + assert.deepEqual(results[0], [ + { + /* eslint-disable dot-notation */ + data: { + kind: SubstrateEventKind.Slash, + validator: 'Alice', + amount: '10000', + } as ISubstrateSlash, + blockNumber: 1, + includeAddresses: ['Alice'], + }, + { + data: { + kind: SubstrateEventKind.DemocracyProposed, + proposalIndex: 4, + proposalHash: 'hash', + deposit: '100000', + proposer: 'Alice', + }, + excludeAddresses: ['Alice'], + blockNumber: 1, + }, + ]); + assert.deepEqual(results[1], [ + { + data: { + kind: SubstrateEventKind.DemocracyStarted, + referendumIndex: 5, + proposalHash: 'hash', + voteThreshold: 'Supermajorityapproval', + endBlock: 123, + }, + blockNumber: 2, + }, + { + data: { + kind: SubstrateEventKind.ElectionCandidacySubmitted, + candidate: 'Alice', + }, + blockNumber: 2, + excludeAddresses: ['Alice'], + } + ]); + done(); + }).catch((err) => done(err)); + }); + + it('should process old and new versions differently', (done) => { + done(); + }); + + it('should fail gracefully to find a kind', (done) => { + // setup fake data + const fakeEvents: IFakeEvent[] = [ + { + section: 'staking', + method: 'Fake', + data: [ 'Alice', '10000' ], + }, + ]; + + const block = constructFakeBlock(1, fakeEvents); + const api = constructFakeApi({}); + + // run test + const processor = new Processor(api); + processor.process(block).then((results) => { + try { + assert.equal(processor.lastBlockNumber, 1); + assert.deepEqual(results, []); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('should fail gracefully to find an extrinsic', (done) => { + // setup fake data + const fakeExtrinsics = [ + { + method: { + sectionName: 'elections', + methodName: 'submitBetterCandidacy', + args: [], + }, + signer: 'Alice', + data: new Uint8Array(), + } + ]; + + const block = constructFakeBlock(1, [], fakeExtrinsics); + const api = constructFakeApi({}); + + // run test + const processor = new Processor(api); + processor.process(block).then((results) => { + try { + assert.equal(processor.lastBlockNumber, 1); + assert.deepEqual(results, []); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('should fail gracefully on invalid option during enrichment', (done) => { + // setup fake data + const fakeEvents: IFakeEvent[] = [ + { + section: 'staking', + method: 'Bonded', + data: [ 'Alice', '10000' ], + }, + ]; + + const block = constructFakeBlock(1, fakeEvents); + + const api = constructFakeApi({ + bonded: async () => { + return { + isNone: true, + isEmpty: true, + isSome: false, + value: null, + unwrap: () => { throw new Error('no value'); }, + }; + }, + }); + + const processor = new Processor(api); + processor.process(block).then((results) => { + try { + assert.equal(processor.lastBlockNumber, 1); + assert.deepEqual(results, []); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('should fail gracefully on invalid api call during enrichment', (done) => { + // setup fake data + const fakeEvents: IFakeEvent[] = [ + { + section: 'staking', + method: 'Bonded', + data: [ 'Alice', '10000' ], + }, + ]; + + const block = constructFakeBlock(1, fakeEvents); + const api = constructFakeApi({ }); + + const processor = new Processor(api); + processor.process(block).then((results) => { + try { + assert.equal(processor.lastBlockNumber, 1); + assert.deepEqual(results, []); + done(); + } catch (err) { + done(err); + } + }); + }); +}); diff --git a/test/unit/events/edgeware/subscriber.spec.ts b/test/unit/events/edgeware/subscriber.spec.ts new file mode 100644 index 00000000000..110d64b3fe6 --- /dev/null +++ b/test/unit/events/edgeware/subscriber.spec.ts @@ -0,0 +1,126 @@ +import chai from 'chai'; +import { Hash, EventRecord, RuntimeVersion } from '@polkadot/types/interfaces'; + +import { constructFakeApi } from './testUtil'; +import Subscriber from '../../../../shared/events/edgeware/subscriber'; + +const { assert } = chai; + +const hashes = [1 as unknown as Hash, 2 as unknown as Hash]; +const events = [ + [{ event: { data: [1] } }] as unknown as EventRecord[], + [{ event: { data: [2] } }, { event: { data: [3, 4] } }] as unknown as EventRecord[], +]; + +const getApi = () => { + return constructFakeApi({ + subscribeNewHeads: (callback) => { + callback({ hash: hashes[0], number: '1' }); + const handle = setTimeout(() => callback({ hash: hashes[1], number: '2' }), 50); + return () => clearInterval(handle); // unsubscribe + }, + 'events.at': (hash) => { + if (hash === hashes[0]) return events[0]; + if (hash === hashes[1]) return events[1]; + assert.fail('events.at called with invalid hash'); + }, + getBlock: (hash) => { + return { + block: { + extrinsics: [], + } + }; + }, + subscribeRuntimeVersion: (callback) => { + callback({ specVersion: 10, specName: 'edgeware' } as unknown as RuntimeVersion); + } + }); +}; + +/* eslint-disable: dot-notation */ +describe('Edgeware Event Subscriber Tests', () => { + it('should callback with block data', (done) => { + // setup mock data + const api = getApi(); + + // setup test class + const subscriber = new Subscriber(api); + + // confirm unsubscribe is no-op + subscriber.unsubscribe(); + + // run test + let seenBlocks = 0; + subscriber.subscribe( + (block) => { + try { + if (seenBlocks === 0) { + // first block + assert.deepEqual(block.header.hash, hashes[0]); + assert.equal(+block.header.number, 1); + assert.lengthOf(block.events, 1); + assert.deepEqual(block.events[0], events[0][0]); + assert.equal(block.versionNumber, 10); + assert.equal(block.versionName, 'edgeware'); + } else if (seenBlocks === 1) { + // second block + assert.deepEqual(block.header.hash, hashes[1]); + assert.equal(+block.header.number, 2); + assert.lengthOf(block.events, 2); + assert.deepEqual(block.events[0], events[1][0]); + assert.deepEqual(block.events[1], events[1][1]); + assert.equal(block.versionNumber, 10); + assert.equal(block.versionName, 'edgeware'); + } else { + assert.fail('invalid hash'); + } + seenBlocks++; + if (seenBlocks === 2) { + done(); + } + } catch (err) { + done(err); + } + } + ); + }); + + it('should unsubscribe', (done) => { + // setup mock data + const api = getApi(); + + // setup test class + const subscriber = new Subscriber(api); + + // run test + let seenBlocks = 0; + subscriber.subscribe( + (block) => { + try { + if (seenBlocks === 0) { + // first block + assert.deepEqual(block.header.hash, hashes[0]); + assert.equal(+block.header.number, 1); + assert.lengthOf(block.events, 1); + assert.deepEqual(block.events[0], events[0][0]); + assert.equal(block.versionNumber, 10); + assert.equal(block.versionName, 'edgeware'); + } else if (seenBlocks === 1) { + // second block + assert.fail('should only process one block'); + } else { + assert.fail('invalid hash'); + } + seenBlocks++; + } catch (err) { + done(err); + } + } + ); + setTimeout(() => { + subscriber.unsubscribe(); + setTimeout(() => done(), 50); + }, 10); + }); + // TODO: fail tests +}); diff --git a/test/unit/events/edgeware/testUtil.ts b/test/unit/events/edgeware/testUtil.ts new file mode 100644 index 00000000000..de8b0ddf431 --- /dev/null +++ b/test/unit/events/edgeware/testUtil.ts @@ -0,0 +1,74 @@ +/* eslint-disable dot-notation */ +import { ApiPromise } from '@polkadot/api'; +import { Option } from '@polkadot/types'; +import { Codec } from '@polkadot/types/types'; + +export function constructOption(value?: T): Option { + if (value) { + return { + isSome: true, + isNone: false, + isEmpty: false, + value, + unwrap: () => value, + } as unknown as Option; + } else { + return { + isSome: false, + isNone: true, + isEmpty: true, + value: undefined, + unwrap: () => { throw new Error('option is null'); } + } as unknown as Option; + } +} + +export function constructFakeApi(callOverrides): ApiPromise { + return { + rpc: { + chain: { + subscribeNewHeads: callOverrides['subscribeNewHeads'], + getHeader: callOverrides['getHeader'], + getBlock: callOverrides['getBlock'], + }, + state: { + getRuntimeVersion: callOverrides['getRuntimeVersion'], + subscribeRuntimeVersion: callOverrides['subscribeRuntimeVersion'], + } + }, + query: { + system: { + blockHash: { + multi: callOverrides['blockHash.multi'], + }, + events: { + at: callOverrides['events.at'], + } + }, + staking: { + bonded: callOverrides['bonded'], + }, + democracy: { + referendumInfoOf: callOverrides['referendumInfoOf'], + publicProps: callOverrides['publicProps'], + }, + treasury: { + proposals: callOverrides['proposals'], + }, + council: { + voting: callOverrides['voting'], + }, + signaling: { + proposalOf: callOverrides['proposalOf'], + } + }, + derive: { + chain: { + bestNumber: callOverrides['bestNumber'], + }, + democracy: { + dispatchQueue: callOverrides['dispatchQueue'], + } + } + } as ApiPromise; +}