Skip to content

Commit

Permalink
Emit events for chain notifications (re-posted after revert) (#57)
Browse files Browse the repository at this point in the history
* 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 <jake@commonwealth.im>
Co-authored-by: Raymond Zhong <raykyri@gmail.com>
  • Loading branch information
3 people committed May 5, 2020
1 parent bd13034 commit 6e71509
Show file tree
Hide file tree
Showing 53 changed files with 4,262 additions and 51 deletions.
3 changes: 3 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
13 changes: 10 additions & 3 deletions client/scripts/controllers/chain/substrate/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,16 @@ export class SubstrateAccount extends Account<SubstrateCoin> {
public get balanceTransferFee(): Observable<SubstrateCoin> {
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');
Expand Down
27 changes: 27 additions & 0 deletions client/scripts/models/ChainEvent.ts
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions client/scripts/models/ChainEventType.ts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 14 additions & 2 deletions client/scripts/models/Notification.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,45 @@
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!');
} else {
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,
);
}
}

Expand Down
2 changes: 2 additions & 0 deletions client/scripts/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
18 changes: 11 additions & 7 deletions client/scripts/views/components/settings/send_edg_well.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
57 changes: 49 additions & 8 deletions client/scripts/views/components/sidebar/notification_row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -128,14 +129,54 @@ const HeaderNotificationRow: m.Component<IHeaderNotificationRow> = {
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);
Expand Down
124 changes: 124 additions & 0 deletions client/scripts/views/pages/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -306,6 +311,122 @@ const CommunitySubscriptions: m.Component<{}, ICommunitySubscriptionsState> = {
}
};

interface IEventSubscriptionRowAttrs {
chain: string;
kind: IChainEventKind;
titler: TitlerFilter;
}

const EventSubscriptionRow: m.Component<IEventSubscriptionRowAttrs, {}> = {
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', [
Expand All @@ -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),
Expand Down
Loading

0 comments on commit 6e71509

Please sign in to comment.