Skip to content

Commit

Permalink
feat(relayer): add publish message api method
Browse files Browse the repository at this point in the history
- [x] Add controller api method for message publishing
- [x] Add tests
- [x] Add dto and validation
  • Loading branch information
0xmad authored and ctrlc03 committed Jan 9, 2025
1 parent e2068a3 commit b083ec7
Show file tree
Hide file tree
Showing 14 changed files with 438 additions and 0 deletions.
1 change: 1 addition & 0 deletions apps/relayer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions apps/relayer/tests/messages.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
3 changes: 3 additions & 0 deletions apps/relayer/ts/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Module } from "@nestjs/common";
import { ThrottlerModule } from "@nestjs/throttler";

import { MessageModule } from "./message/message.module";

@Module({
imports: [
ThrottlerModule.forRoot([
Expand All @@ -9,6 +11,7 @@ import { ThrottlerModule } from "@nestjs/throttler";
limit: Number(process.env.LIMIT),
},
]),
MessageModule,
],
})
export class AppModule {}
55 changes: 55 additions & 0 deletions apps/relayer/ts/message/__tests__/message.controller.test.ts
Original file line number Diff line number Diff line change
@@ -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>(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),
);
});
});
});
21 changes: 21 additions & 0 deletions apps/relayer/ts/message/__tests__/message.service.test.ts
Original file line number Diff line number Diff line change
@@ -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: "" });
});
});
15 changes: 15 additions & 0 deletions apps/relayer/ts/message/__tests__/utils.ts
Original file line number Diff line number Diff line change
@@ -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(),
},
],
};
30 changes: 30 additions & 0 deletions apps/relayer/ts/message/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
88 changes: 88 additions & 0 deletions apps/relayer/ts/message/dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
41 changes: 41 additions & 0 deletions apps/relayer/ts/message/message.controller.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return this.messageService.saveMessages(args).catch((error: Error) => {
this.logger.error(`Error:`, error);
throw new HttpException(error.message, HttpStatus.BAD_REQUEST);
});
}
}
10 changes: 10 additions & 0 deletions apps/relayer/ts/message/message.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading

0 comments on commit b083ec7

Please sign in to comment.