diff --git a/apps/relayer/package.json b/apps/relayer/package.json index 649a03e07..b5aa86a57 100644 --- a/apps/relayer/package.json +++ b/apps/relayer/package.json @@ -41,6 +41,7 @@ "hardhat": "^2.22.15", "helmet": "^8.0.0", "maci-contracts": "workspace:^2.5.0", + "maci-domainobjs": "workspace:^2.5.0", "mustache": "^4.2.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", diff --git a/apps/relayer/tests/messages.test.ts b/apps/relayer/tests/messages.test.ts new file mode 100644 index 000000000..72d59ac63 --- /dev/null +++ b/apps/relayer/tests/messages.test.ts @@ -0,0 +1,89 @@ +import { HttpStatus, ValidationPipe, type INestApplication } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { ZeroAddress } from "ethers"; +import { Keypair } from "maci-domainobjs"; +import request from "supertest"; + +import type { App } from "supertest/types"; + +import { AppModule } from "../ts/app.module"; + +describe("e2e messages", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.listen(3001); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("/v1/messages/publish", () => { + const keypair = new Keypair(); + + const defaultSaveMessagesArgs = { + maciContractAddress: ZeroAddress, + poll: 0, + messages: [ + { + data: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], + publicKey: keypair.pubKey.serialize(), + }, + ], + }; + + test("should throw an error if dto is invalid", async () => { + const result = await request(app.getHttpServer() as App) + .post("/v1/messages/publish") + .send({ + maciContractAddress: "invalid", + poll: "-1", + messages: [], + }) + .expect(HttpStatus.BAD_REQUEST); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: [ + "poll must not be less than 0", + "poll must be an integer number", + "maciContractAddress must be an Ethereum address", + "messages must contain at least 1 elements", + ], + }); + }); + + test("should throw an error if messages dto is invalid", async () => { + const result = await request(app.getHttpServer() as App) + .post("/v1/messages/publish") + .send({ + ...defaultSaveMessagesArgs, + messages: [{ data: [], publicKey: "invalid" }], + }) + .expect(HttpStatus.BAD_REQUEST); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: ["messages.0.data must contain at least 10 elements", "messages.0.Public key (invalid) is invalid"], + }); + }); + + test("should publish user messages properly", async () => { + const result = await request(app.getHttpServer() as App) + .post("/v1/messages/publish") + .send(defaultSaveMessagesArgs) + .expect(HttpStatus.CREATED); + + expect(result.status).toBe(HttpStatus.CREATED); + }); + }); +}); diff --git a/apps/relayer/ts/app.module.ts b/apps/relayer/ts/app.module.ts index b1a904677..fdc8e1fa6 100644 --- a/apps/relayer/ts/app.module.ts +++ b/apps/relayer/ts/app.module.ts @@ -1,6 +1,8 @@ import { Module } from "@nestjs/common"; import { ThrottlerModule } from "@nestjs/throttler"; +import { MessageModule } from "./message/message.module"; + @Module({ imports: [ ThrottlerModule.forRoot([ @@ -9,6 +11,7 @@ import { ThrottlerModule } from "@nestjs/throttler"; limit: Number(process.env.LIMIT), }, ]), + MessageModule, ], }) export class AppModule {} diff --git a/apps/relayer/ts/message/__tests__/message.controller.test.ts b/apps/relayer/ts/message/__tests__/message.controller.test.ts new file mode 100644 index 000000000..2451685a8 --- /dev/null +++ b/apps/relayer/ts/message/__tests__/message.controller.test.ts @@ -0,0 +1,55 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; + +import { MessageController } from "../message.controller"; +import { MessageService } from "../message.service"; + +import { defaultSaveMessagesArgs } from "./utils"; + +describe("MessageController", () => { + let controller: MessageController; + + const mockMessageService = { + saveMessages: jest.fn(), + merge: jest.fn(), + }; + + beforeEach(async () => { + const app = await Test.createTestingModule({ + controllers: [MessageController], + }) + .useMocker((token) => { + if (token === MessageService) { + mockMessageService.saveMessages.mockResolvedValue(true); + + return mockMessageService; + } + + return jest.fn(); + }) + .compile(); + + controller = app.get(MessageController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("v1/messages/publish", () => { + test("should publish user messages properly", async () => { + const data = await controller.publish(defaultSaveMessagesArgs); + + expect(data).toBe(true); + }); + + test("should throw an error if messages saving is failed", async () => { + const error = new Error("error"); + mockMessageService.saveMessages.mockRejectedValue(error); + + await expect(controller.publish(defaultSaveMessagesArgs)).rejects.toThrow( + new HttpException(error.message, HttpStatus.BAD_REQUEST), + ); + }); + }); +}); diff --git a/apps/relayer/ts/message/__tests__/message.service.test.ts b/apps/relayer/ts/message/__tests__/message.service.test.ts new file mode 100644 index 000000000..114b8d326 --- /dev/null +++ b/apps/relayer/ts/message/__tests__/message.service.test.ts @@ -0,0 +1,21 @@ +import { MessageService } from "../message.service"; + +import { defaultSaveMessagesArgs } from "./utils"; + +describe("MessageService", () => { + test("should save messages properly", async () => { + const service = new MessageService(); + + const result = await service.saveMessages(defaultSaveMessagesArgs); + + expect(result).toBe(true); + }); + + test("should publish messages properly", async () => { + const service = new MessageService(); + + const result = await service.publishMessages(defaultSaveMessagesArgs); + + expect(result).toStrictEqual({ hash: "", ipfsHash: "" }); + }); +}); diff --git a/apps/relayer/ts/message/__tests__/utils.ts b/apps/relayer/ts/message/__tests__/utils.ts new file mode 100644 index 000000000..3852210ea --- /dev/null +++ b/apps/relayer/ts/message/__tests__/utils.ts @@ -0,0 +1,15 @@ +import { ZeroAddress } from "ethers"; +import { Keypair } from "maci-domainobjs"; + +const keypair = new Keypair(); + +export const defaultSaveMessagesArgs = { + maciContractAddress: ZeroAddress, + poll: 0, + messages: [ + { + data: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], + publicKey: keypair.pubKey.serialize(), + }, + ], +}; diff --git a/apps/relayer/ts/message/__tests__/validation.test.ts b/apps/relayer/ts/message/__tests__/validation.test.ts new file mode 100644 index 000000000..fe7e72671 --- /dev/null +++ b/apps/relayer/ts/message/__tests__/validation.test.ts @@ -0,0 +1,30 @@ +import { Keypair } from "maci-domainobjs"; + +import { PublicKeyValidator } from "../validation"; + +describe("PublicKeyValidator", () => { + test("should validate valid public key", () => { + const keypair = new Keypair(); + const validator = new PublicKeyValidator(); + + const result = validator.validate(keypair.pubKey.serialize()); + + expect(result).toBe(true); + }); + + test("should validate invalid public key", () => { + const validator = new PublicKeyValidator(); + + const result = validator.validate("invalid"); + + expect(result).toBe(false); + }); + + test("should return default message properly", () => { + const validator = new PublicKeyValidator(); + + const result = validator.defaultMessage(); + + expect(result).toBe("Public key ($value) is invalid"); + }); +}); diff --git a/apps/relayer/ts/message/dto.ts b/apps/relayer/ts/message/dto.ts new file mode 100644 index 000000000..a5e25e701 --- /dev/null +++ b/apps/relayer/ts/message/dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsEthereumAddress, + IsInt, + Min, + Validate, + IsArray, + ArrayMinSize, + ArrayMaxSize, + ValidateNested, +} from "class-validator"; +import { Message } from "maci-domainobjs"; + +import { PublicKeyValidator } from "./validation"; + +/** + * Max messages per batch + */ +const MAX_MESSAGES = 20; + +/** + * Data transfer object for user message + */ +export class MessageContractParamsDto { + /** + * Message data + */ + @ApiProperty({ + description: "Message data", + type: [String], + }) + @IsArray() + @ArrayMinSize(Message.DATA_LENGTH) + @ArrayMaxSize(Message.DATA_LENGTH) + data!: string[]; + + /** + * Public key + */ + @ApiProperty({ + description: "Public key", + type: String, + }) + @Validate(PublicKeyValidator) + publicKey!: string; +} + +/** + * Data transfer object for publish messages + */ +export class PublishMessagesDto { + /** + * Poll id + */ + @ApiProperty({ + description: "Poll id", + minimum: 0, + type: Number, + }) + @IsInt() + @Min(0) + poll!: number; + + /** + * Maci contract address + */ + @ApiProperty({ + description: "MACI contract address", + type: String, + }) + @IsEthereumAddress() + maciContractAddress!: string; + + /** + * Messages + */ + @ApiProperty({ + description: "User messages with public key", + type: [MessageContractParamsDto], + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(MAX_MESSAGES) + @ValidateNested({ each: true }) + @Type(() => MessageContractParamsDto) + messages!: MessageContractParamsDto[]; +} diff --git a/apps/relayer/ts/message/message.controller.ts b/apps/relayer/ts/message/message.controller.ts new file mode 100644 index 000000000..1f2da30dc --- /dev/null +++ b/apps/relayer/ts/message/message.controller.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { Body, Controller, HttpException, HttpStatus, Logger, Post } from "@nestjs/common"; +import { ApiBearerAuth, ApiBody, ApiResponse, ApiTags } from "@nestjs/swagger"; + +import { PublishMessagesDto } from "./dto"; +import { MessageService } from "./message.service"; + +@ApiTags("v1/messages") +@ApiBearerAuth() +@Controller("v1/messages") +export class MessageController { + /** + * Logger + */ + private readonly logger = new Logger(MessageController.name); + + /** + * Initialize MessageController + * + */ + constructor(private readonly messageService: MessageService) {} + + /** + * Publish user messages api method. + * Saves messages batch and then send them onchain by calling `publishMessages` method via cron job. + * + * @param args - publish messages dto + * @returns success or not + */ + @ApiBody({ type: PublishMessagesDto }) + @ApiResponse({ status: HttpStatus.CREATED, description: "The messages have been successfully accepted" }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: "Forbidden" }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: "BadRequest" }) + @Post("publish") + async publish(@Body() args: PublishMessagesDto): Promise { + return this.messageService.saveMessages(args).catch((error: Error) => { + this.logger.error(`Error:`, error); + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + }); + } +} diff --git a/apps/relayer/ts/message/message.module.ts b/apps/relayer/ts/message/message.module.ts new file mode 100644 index 000000000..d24abda23 --- /dev/null +++ b/apps/relayer/ts/message/message.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; + +import { MessageController } from "./message.controller"; +import { MessageService } from "./message.service"; + +@Module({ + controllers: [MessageController], + providers: [MessageService], +}) +export class MessageModule {} diff --git a/apps/relayer/ts/message/message.service.ts b/apps/relayer/ts/message/message.service.ts new file mode 100644 index 000000000..c554f6a62 --- /dev/null +++ b/apps/relayer/ts/message/message.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from "@nestjs/common"; + +import type { PublishMessagesDto } from "./dto"; +import type { IPublishMessagesReturn } from "./types"; + +/** + * MessageService is responsible for saving message batches and send them onchain + */ +@Injectable() +export class MessageService { + /** + * Logger + */ + private readonly logger: Logger = new Logger(MessageService.name); + + /** + * Save messages batch + * + * @param args - publish messages dto + * @returns success or not + */ + async saveMessages(args: PublishMessagesDto): Promise { + this.logger.log("Save messages", args); + return Promise.resolve(true); + } + + /** + * Publish messages onchain + * + * @param args - publish messages dto + * @returns transaction and ipfs hashes + */ + async publishMessages(args: PublishMessagesDto): Promise { + this.logger.log("Publish messages", args); + return Promise.resolve({ hash: "", ipfsHash: "" }); + } +} diff --git a/apps/relayer/ts/message/types.ts b/apps/relayer/ts/message/types.ts new file mode 100644 index 000000000..cd15824c2 --- /dev/null +++ b/apps/relayer/ts/message/types.ts @@ -0,0 +1,13 @@ +/** + * Publish messages return type + */ +export interface IPublishMessagesReturn { + /** + * Transaction hash + */ + hash: string; + /** + * IPFS hash for messages batch + */ + ipfsHash: string; +} diff --git a/apps/relayer/ts/message/validation.ts b/apps/relayer/ts/message/validation.ts new file mode 100644 index 000000000..598910ddf --- /dev/null +++ b/apps/relayer/ts/message/validation.ts @@ -0,0 +1,32 @@ +import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator"; +import { PubKey } from "maci-domainobjs"; + +/** + * Validate public key + */ +@ValidatorConstraint({ name: "publicKey", async: false }) +export class PublicKeyValidator implements ValidatorConstraintInterface { + /** + * Try to deserialize public key from text and return status of validation + * + * @param text - text to validate + * @returns status of validation + */ + validate(text: string): boolean { + try { + const [x, y] = PubKey.deserialize(text).asArray(); + return Boolean(new PubKey([x, y])); + } catch (error) { + return false; + } + } + + /** + * Return default validation message + * + * @returns default validation message + */ + defaultMessage(): string { + return "Public key ($value) is invalid"; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca2d6a5b6..fe81febef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: maci-contracts: specifier: workspace:^2.5.0 version: link:../../packages/contracts + maci-domainobjs: + specifier: workspace:^2.5.0 + version: link:../../packages/domainobjs mustache: specifier: ^4.2.0 version: 4.2.0