From 57b927a946bfb1d3460cb19a33e73e1282b68b11 Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Mon, 11 Sep 2023 18:39:28 +0200 Subject: [PATCH] WIP --- package-lock.json | 94 ++++++++++++++++++++- package.json | 1 + src/routes/events/events.controller.spec.ts | 59 ++++++++++++- src/routes/events/events.controller.ts | 12 ++- src/routes/events/events.module.ts | 5 +- src/routes/events/events.service.ts | 40 +++++++++ test/app.e2e-spec.ts | 28 ++++++ 7 files changed, 228 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6caa1c4..d31ef30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "safe-events-service", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "safe-events-service", - "version": "0.2.1", + "version": "0.3.0", "license": "UNLICENSED", "dependencies": { "@adminjs/express": "^6.0.0", @@ -26,6 +26,7 @@ "amqplib": "^0.10.3", "axios": "^1.4.0", "cache-manager": "^5.2.1", + "ethers": "^6.7.1", "express-formidable": "^1.2.0", "express-session": "^1.17.3", "pg": "^8.11.3", @@ -162,6 +163,11 @@ "typeorm": "~0.3.0" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.9.2.tgz", + "integrity": "sha512-0h+FrQDqe2Wn+IIGFkTCd4aAwTJ+7834Ek1COohCyV26AXhwQ7WQaz+4F/nLOeVl/3BtWHOHLPsq46V8YB46Eg==" + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -3589,6 +3595,28 @@ "typeorm": "^0.3.0" } }, + "node_modules/@noble/hashes": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.2.tgz", + "integrity": "sha512-KYRCASVTv6aeUi1tsF8/vpyR7zpfs3FUzy2Jqm+MU+LmUKhQ0y2FpfwqkCcxSg2ua4GALJd8k2R76WxwZGbQpA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@noble/secp256k1": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", + "integrity": "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5584,6 +5612,11 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -7497,6 +7530,43 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.7.1.tgz", + "integrity": "sha512-qX5kxIFMfg1i+epfgb0xF4WM7IqapIIu50pOJ17aebkxxa4BacW5jFrQRmCJpDEg2ZK2oNtR5QjrQ1WDBF29dA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "1.9.2", + "@noble/hashes": "1.1.2", + "@noble/secp256k1": "1.7.1", + "@types/node": "18.15.13", + "aes-js": "4.0.0-beta.5", + "tslib": "2.4.0", + "ws": "8.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "18.15.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", + "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==" + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -13633,6 +13703,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xss": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.14.tgz", diff --git a/package.json b/package.json index ffde14c..84146af 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "amqplib": "^0.10.3", "axios": "^1.4.0", "cache-manager": "^5.2.1", + "ethers": "^6.7.1", "express-formidable": "^1.2.0", "express-session": "^1.17.3", "pg": "^8.11.3", diff --git a/src/routes/events/events.controller.spec.ts b/src/routes/events/events.controller.spec.ts index 6e45c1f..3ea7f63 100644 --- a/src/routes/events/events.controller.spec.ts +++ b/src/routes/events/events.controller.spec.ts @@ -1,18 +1,69 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EventsController } from './events.controller'; +import { EventsService } from './events.service'; +import { QueueProvider } from '../../datasources/queue/queue.provider'; +import { WebhookService } from '../webhook/webhook.service'; +import { firstValueFrom } from 'rxjs'; +import { TxServiceEvent, TxServiceEventType } from './event.dto'; +import { BadRequestException } from '@nestjs/common'; describe('EventsController', () => { let controller: EventsController; + let service: EventsService; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + const module = await Test.createTestingModule({ controllers: [EventsController], - }).compile(); + providers: [EventsService, QueueProvider, WebhookService], + }) + .overrideProvider(QueueProvider) + .useValue({}) + .overrideProvider(WebhookService) + .useValue({}) + .compile(); controller = module.get(EventsController); + service = module.get(EventsService); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + describe('SSE events', () => { + it('should require an EIP55 address', async () => { + const notValidAddress = '0x8618CE407F169ABB1388348A19632AaFA857CCB9'; + const expectedError = new BadRequestException('Not valid EIP55 address', { + description: `${notValidAddress} is not a valid EIP55 Safe address`, + }); + expect(() => { + controller.sse(notValidAddress); + }).toThrow(expectedError); + }); + it('should receive for a Safe', async () => { + const relevantSafeAddress = '0x8618ce407F169ABB1388348A19632AaFA857CCB9'; + const notRelevantSafeAddress = + '0x3F6E283068Ded118459B56fC669A27C3a65e587D'; + const txServiceEvents: Array = [ + { + chainId: '1', + type: 'SAFE_CREATED' as TxServiceEventType, + hero: 'Saitama', + address: notRelevantSafeAddress, + }, + { + chainId: '100', + type: 'MESSAGE_CREATED' as TxServiceEventType, + hero: 'Atomic Samurai', + address: relevantSafeAddress, + }, + ]; + const observable = controller.sse(relevantSafeAddress); + const firstValue = firstValueFrom(observable); + txServiceEvents.map((txServiceEvent) => + service.pushEventToEventsObservable(txServiceEvent), + ); + + // Not relevant event must be ignored by Safe filter + const event = await firstValue; + expect(event.data).toEqual(txServiceEvents[1]); + expect(event.type).toEqual(txServiceEvents[1].type); + }); }); }); diff --git a/src/routes/events/events.controller.ts b/src/routes/events/events.controller.ts index 6dbb51b..429df38 100644 --- a/src/routes/events/events.controller.ts +++ b/src/routes/events/events.controller.ts @@ -1,13 +1,19 @@ -import { Controller, Param, Sse } from '@nestjs/common'; +import { BadRequestException, Controller, Param, Sse } from '@nestjs/common'; import { Observable } from 'rxjs'; import { EventsService } from './events.service'; +import { getAddress, isAddress } from 'ethers'; @Controller('events') export class EventsController { - constructor(private eventsService: EventsService) {} + constructor(private readonly eventsService: EventsService) {} @Sse('/sse/:safe') sse(@Param('safe') safe: string): Observable { - return interval(1000).pipe(map((_) => ({ data: { hello: 'world' } }))); + if (isAddress(safe) && getAddress(safe) === safe) + return this.eventsService.getEventsObservableForSafe(safe); + + throw new BadRequestException('Not valid EIP55 address', { + description: `${safe} is not a valid EIP55 Safe address`, + }); } } diff --git a/src/routes/events/events.module.ts b/src/routes/events/events.module.ts index 2de8c11..49a5879 100644 --- a/src/routes/events/events.module.ts +++ b/src/routes/events/events.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { EventsController } from './events.controller'; import { EventsService } from './events.service'; -import { WebhookModule } from '../webhook/webhook.module'; import { QueueModule } from '../../datasources/queue/queue.module'; +import { WebhookModule } from '../webhook/webhook.module'; @Module({ imports: [QueueModule, WebhookModule], - // controllers: [Controller], + controllers: [EventsController], providers: [EventsService], }) export class EventsModule {} diff --git a/src/routes/events/events.service.ts b/src/routes/events/events.service.ts index 8ce727c..d5f5fe9 100644 --- a/src/routes/events/events.service.ts +++ b/src/routes/events/events.service.ts @@ -1,3 +1,4 @@ +import { Observable, Subject, filter } from 'rxjs'; import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { WebhookService } from '../webhook/webhook.service'; import { QueueProvider } from '../../datasources/queue/queue.provider'; @@ -7,6 +8,7 @@ import { TxServiceEvent } from './event.dto'; @Injectable() export class EventsService implements OnApplicationBootstrap { private readonly logger = new Logger(EventsService.name); + private eventsSubject = new Subject>(); constructor( private readonly queueProvider: QueueProvider, @@ -24,6 +26,43 @@ export class EventsService implements OnApplicationBootstrap { ); } + /** + * + * @param safe + * @returns Events rx.js observable used by the Server Side Events endpoint + */ + getEventsObservableForSafe( + safe: string, + ): Observable> { + return this.eventsSubject.pipe(filter((ev) => ev.data.address === safe)); + } + + /** + * Push txServiceEvent to the events observable (used by the Server Side Events endpoint) + * @param txServiceEvent + * @returns Crafted MessageEvent from txServiceEvent + */ + pushEventToEventsObservable( + txServiceEvent: TxServiceEvent, + ): MessageEvent { + const messageEvent: MessageEvent = new MessageEvent( + txServiceEvent.type, + { + data: txServiceEvent, + }, + ); + this.eventsSubject.next(messageEvent); + return messageEvent; + } + + /** + * Complete event observable + * This function is useful for testing purposes + */ + completeEventsObservable() { + return this.eventsSubject.complete(); + } + /** * * Event must have at least a `chainId` and `type` @@ -55,6 +94,7 @@ export class EventsService implements OnApplicationBootstrap { return Promise.resolve([undefined]); } + this.pushEventToEventsObservable(txServiceEvent); return this.webhookService.postEveryWebhook(txServiceEvent); } } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 126e38c..42c22c6 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -3,6 +3,7 @@ import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from './../src/app.module'; import { QueueProvider } from '../src/datasources/queue/queue.provider'; +import { EventsService } from '../src/routes/events/events.service'; /* eslint-disable */ const { version } = require('../package.json'); @@ -11,12 +12,14 @@ const { version } = require('../package.json'); describe('AppController (e2e)', () => { let app: INestApplication; let queueProvider: QueueProvider; + let eventsService: EventsService; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); + eventsService = moduleFixture.get(EventsService); queueProvider = moduleFixture.get(QueueProvider); app = moduleFixture.createNestApplication(); await app.init(); @@ -34,4 +37,29 @@ describe('AppController (e2e)', () => { .expect(200) .expect(expected); }); + + describe('/events/sse/:safe (GET)', () => { + it('should subscribe to server side events', () => { + const validSafeAddress = '0x8618ce407F169ABB1388348A19632AaFA857CCB9'; + const url = `/events/sse/${validSafeAddress}`; + const expected = {}; + + const result = request(app.getHttpServer()) + .get(url) + .expect(200) + .expect(expected); + eventsService.completeEventsObservable(); + return result; + }); + it('should return a 400 if safe address is not EIP55 valid', () => { + const notValidAddress = '0x8618CE407F169ABB1388348A19632AaFA857CCB9'; + const url = `/events/sse/${notValidAddress}`; + const expected = { + statusCode: 400, + message: 'Not valid EIP55 address', + error: `${notValidAddress} is not a valid EIP55 Safe address`, + }; + return request(app.getHttpServer()).get(url).expect(400).expect(expected); + }); + }); });