diff --git a/docker-setup/docker-compose.dev.yml b/docker-setup/docker-compose.dev.yml index b329016fb9..e7555180cb 100644 --- a/docker-setup/docker-compose.dev.yml +++ b/docker-setup/docker-compose.dev.yml @@ -91,7 +91,7 @@ services: timeout: 5s retries: 5 volumes: - - graph-node-db-data:/var/lib/postgresql/data:Z + - graph-node-db-data:/var/lib/postgresql:Z environment: POSTGRES_USER: *graph_db_user POSTGRES_PASSWORD: *graph_db_passwrod diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 529154b0ed..fd0187533e 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -58,6 +58,8 @@ import { OperatorController } from './modules/user-operator/operator.controller' import { OperatorModule } from './modules/user-operator/operator.module'; import { WorkerController } from './modules/user-worker/worker.controller'; import { WorkerModule } from './modules/user-worker/worker.module'; +import { ExchangeApiKeysModule } from './modules/exchange-api-keys/exchange-api-keys.module'; +import { ExchangeApiKeysController } from './modules/exchange-api-keys/exchange-api-keys.controller'; const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); @@ -147,6 +149,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); NDAModule, AbuseModule, GovernanceModule, + ExchangeApiKeysModule, ], controllers: [ AppController, @@ -162,6 +165,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); NDAController, AbuseController, GovernanceController, + ExchangeApiKeysController, ], exports: [HttpModule], providers: [ diff --git a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts index 7f7c548fb2..facb921ecf 100644 --- a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts @@ -141,6 +141,21 @@ export class GatewayConfigService { method: HttpMethod.GET, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_ENROLL]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.POST, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_DELETE]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.DELETE, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_RETRIEVE]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, } as Record, }, [ExternalApiName.HCAPTCHA_LABELING_STATS]: { diff --git a/packages/apps/human-app/server/src/common/enums/http-method.ts b/packages/apps/human-app/server/src/common/enums/http-method.ts index 5a3bbf6de1..4dfcef38b9 100644 --- a/packages/apps/human-app/server/src/common/enums/http-method.ts +++ b/packages/apps/human-app/server/src/common/enums/http-method.ts @@ -1,4 +1,5 @@ export enum HttpMethod { GET = 'GET', POST = 'POST', + DELETE = 'DELETE', } diff --git a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts index bedd90d15d..2271ce18d5 100644 --- a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts +++ b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts @@ -22,6 +22,9 @@ export enum ReputationOracleEndpoints { SIGN_NDA = 'sign_nda', REPORT_ABUSE = 'report_abuse', GET_ABUSE_REPORTS = 'get_abuse_reports', + EXCHANGE_API_KEYS_ENROLL = 'exchange_api_keys_enroll', + EXCHANGE_API_KEYS_DELETE = 'exchange_api_keys_delete', + EXCHANGE_API_KEYS_RETRIEVE = 'exchange_api_keys_retrieve', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', diff --git a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts index 1a06cd60ee..5d79c29c28 100644 --- a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts +++ b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts @@ -44,6 +44,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { status: string; wallet_address: string; reputation_network: string; + is_stake_eligible?: boolean; qualifications?: string[]; site_key?: string; email?: string; @@ -58,6 +59,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { wallet_address: payload.wallet_address, status: payload.status, reputation_network: payload.reputation_network, + is_stake_eligible: payload.is_stake_eligible, qualifications: payload.qualifications, site_key: payload.site_key, email: payload.email, diff --git a/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts b/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts index 85da60b3c1..a0175c642d 100644 --- a/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts +++ b/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts @@ -10,6 +10,8 @@ export class JwtUserData { @AutoMap() reputation_network: string; @AutoMap() + is_stake_eligible?: boolean; + @AutoMap() email?: string; @AutoMap() qualifications?: string[]; diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts index adebda27cf..bd9a285233 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts @@ -95,6 +95,11 @@ import { ReportAbuseParams, ReportedAbuseResponse, } from '../../modules/abuse/model/abuse.model'; +import { HttpMethod } from '../../common/enums/http-method'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, +} from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; @Injectable() export class ReputationOracleGateway { @@ -136,6 +141,44 @@ export class ReputationOracleGateway { const response = await lastValueFrom(this.httpService.request(options)); return response.data as T; } + + async enrollExchangeApiKeys( + command: EnrollExchangeApiKeysCommand, + ): Promise<{ id: number }> { + const enrollExchangeApiKeysData = this.mapper.map( + command, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, + ); + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_ENROLL, + enrollExchangeApiKeysData, + command.token, + ); + options.url = `${options.url}/${command.exchangeName}`; + return this.handleRequestToReputationOracle<{ id: number }>(options); + } + + async deleteExchangeApiKeys(token: string) { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_DELETE, + undefined, + token, + ); + options.method = HttpMethod.DELETE; + return this.handleRequestToReputationOracle(options); + } + + async retrieveExchangeApiKeys(token: string): Promise<{ apiKey: string }> { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_RETRIEVE, + undefined, + token, + ); + return this.handleRequestToReputationOracle<{ + apiKey: string; + }>(options); + } async sendWorkerSignup(command: SignupWorkerCommand): Promise { const signupWorkerData = this.mapper.map( command, diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts index 1c147e2833..3488762b01 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts @@ -62,6 +62,10 @@ import { ReportAbuseData, ReportAbuseParams, } from '../../modules/abuse/model/abuse.model'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, +} from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; @Injectable() export class ReputationOracleProfile extends AutomapperProfile { @@ -166,6 +170,15 @@ export class ReputationOracleProfile extends AutomapperProfile { destination: new SnakeCaseNamingConvention(), }), ); + createMap( + mapper, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); }; } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts new file mode 100644 index 0000000000..620001c690 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Request, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { RequestWithUser } from '../../common/interfaces/jwt'; +import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange-api-keys.service'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, + RetrieveExchangeApiKeysResponse, +} from './model/exchange-api-keys.model'; + +@ApiTags('Exchange-Api-Keys') +@ApiBearerAuth() +@Controller('/exchange-api-keys') +export class ExchangeApiKeysController { + constructor( + private readonly service: ExchangeApiKeysService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @ApiOperation({ summary: 'Enroll API keys for exchange' }) + @ApiBody({ type: EnrollExchangeApiKeysDto }) + @ApiResponse({ status: 200, description: 'Exchange API keys enrolled' }) + @HttpCode(200) + @Post('/:exchange_name') + async enroll( + @Param('exchange_name') exchangeName: string, + @Body() dto: EnrollExchangeApiKeysDto, + @Request() req: RequestWithUser, + ): Promise<{ id: number }> { + const command = this.mapper.map( + dto, + EnrollExchangeApiKeysDto, + EnrollExchangeApiKeysCommand, + ); + command.token = req.token; + command.exchangeName = exchangeName; + return this.service.enroll(command); + } + + @ApiOperation({ summary: 'Delete API keys for exchange' }) + @ApiResponse({ status: 204, description: 'Exchange API keys deleted' }) + @HttpCode(204) + @Delete('/') + async delete(@Request() req: RequestWithUser): Promise { + await this.service.delete(req.token); + } + + @ApiOperation({ + summary: 'Retrieve API keys for exchange', + }) + @Get('/') + async retrieve( + @Request() req: RequestWithUser, + ): Promise { + return this.service.retrieve(req.token); + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts new file mode 100644 index 0000000000..5f65c8f475 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts @@ -0,0 +1,20 @@ +import { Mapper, createMap } from '@automapper/core'; +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, +} from './model/exchange-api-keys.model'; + +@Injectable() +export class ExchangeApiKeysProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap(mapper, EnrollExchangeApiKeysDto, EnrollExchangeApiKeysCommand); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts new file mode 100644 index 0000000000..3b6d627810 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ExchangeApiKeysController } from '../../modules/exchange-api-keys/exchange-api-keys.controller'; +import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange-api-keys.service'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; +import { ExchangeApiKeysProfile } from './exchange-api-keys.mapper.profile'; + +@Module({ + imports: [ReputationOracleModule], + controllers: [ExchangeApiKeysController], + providers: [ExchangeApiKeysService, ExchangeApiKeysProfile], + exports: [ExchangeApiKeysService], +}) +export class ExchangeApiKeysModule {} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts new file mode 100644 index 0000000000..f42d311e3b --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { + EnrollExchangeApiKeysCommand, + RetrieveExchangeApiKeysResponse, +} from './model/exchange-api-keys.model'; + +@Injectable() +export class ExchangeApiKeysService { + constructor(private readonly reputationOracle: ReputationOracleGateway) {} + + enroll(command: EnrollExchangeApiKeysCommand): Promise<{ id: number }> { + return this.reputationOracle.enrollExchangeApiKeys(command); + } + + delete(token: string): Promise { + return this.reputationOracle.deleteExchangeApiKeys(token); + } + + retrieve(token: string): Promise { + return this.reputationOracle.retrieveExchangeApiKeys(token); + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts new file mode 100644 index 0000000000..2a22018705 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts @@ -0,0 +1,35 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class EnrollExchangeApiKeysDto { + @AutoMap() + @IsString() + @ApiProperty() + apiKey: string; + + @AutoMap() + @IsString() + @ApiProperty() + secretKey: string; +} + +export class EnrollExchangeApiKeysCommand { + @AutoMap() + apiKey: string; + @AutoMap() + secretKey: string; + token: string; + exchangeName: string; +} + +export class EnrollExchangeApiKeysData { + @AutoMap() + apiKey: string; + @AutoMap() + secretKey: string; +} + +export class RetrieveExchangeApiKeysResponse { + apiKey: string; +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts new file mode 100644 index 0000000000..b260195d86 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts @@ -0,0 +1,85 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RequestWithUser } from '../../../common/interfaces/jwt'; +import { ExchangeApiKeysController } from '../exchange-api-keys.controller'; +import { ExchangeApiKeysService } from '../exchange-api-keys.service'; +import { + enrollExchangeApiKeysCommandFixture, + enrollExchangeApiKeysDtoFixture, + enrollExchangeApiKeysResponseFixture, + EXCHANGE_NAME, + retrieveExchangeApiKeysResponseFixture, + TOKEN, +} from './exchange-api-keys.fixtures'; +import { exchangeApiKeysServiceMock } from './exchange-api-keys.service.mock'; +import { ExchangeApiKeysProfile } from '../exchange-api-keys.mapper.profile'; + +describe('ExchangeApiKeysController', () => { + let controller: ExchangeApiKeysController; + let service: ExchangeApiKeysService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ExchangeApiKeysController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + ExchangeApiKeysService, + ExchangeApiKeysProfile, + { + provide: ExchangeApiKeysService, + useValue: exchangeApiKeysServiceMock, + }, + ], + }) + .overrideProvider(ExchangeApiKeysService) + .useValue(exchangeApiKeysServiceMock) + .compile(); + + controller = module.get( + ExchangeApiKeysController, + ); + service = module.get(ExchangeApiKeysService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('enroll', () => { + it('should call service.enroll with mapped command and return id', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.enroll( + EXCHANGE_NAME, + enrollExchangeApiKeysDtoFixture, + req, + ); + expect(service.enroll).toHaveBeenCalledWith( + enrollExchangeApiKeysCommandFixture, + ); + expect(result).toEqual(enrollExchangeApiKeysResponseFixture); + }); + }); + + describe('delete', () => { + it('should call service.delete with token', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.delete(req); + expect(service.delete).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(undefined); + }); + }); + + describe('retrieve', () => { + it('should call service.retrieve with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.retrieve(req); + expect(service.retrieve).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts new file mode 100644 index 0000000000..2c74f9b6c4 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts @@ -0,0 +1,33 @@ +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, + RetrieveExchangeApiKeysResponse, +} from '../model/exchange-api-keys.model'; + +export const EXCHANGE_NAME = 'mexc'; +export const TOKEN = 'test_user_token'; +export const API_KEY = 'test_api_key'; +export const API_SECRET = 'test_api_secret'; +export const ID = 123; + +export const enrollExchangeApiKeysDtoFixture: EnrollExchangeApiKeysDto = { + apiKey: API_KEY, + secretKey: API_SECRET, +}; + +export const enrollExchangeApiKeysCommandFixture: EnrollExchangeApiKeysCommand = + { + apiKey: API_KEY, + secretKey: API_SECRET, + token: TOKEN, + exchangeName: EXCHANGE_NAME, + }; + +export const enrollExchangeApiKeysResponseFixture = { + id: ID, +}; + +export const retrieveExchangeApiKeysResponseFixture: RetrieveExchangeApiKeysResponse = + { + apiKey: API_KEY, + }; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts new file mode 100644 index 0000000000..d08c84b0c1 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts @@ -0,0 +1,10 @@ +import { + enrollExchangeApiKeysResponseFixture, + retrieveExchangeApiKeysResponseFixture, +} from './exchange-api-keys.fixtures'; + +export const exchangeApiKeysServiceMock = { + enroll: jest.fn().mockReturnValue(enrollExchangeApiKeysResponseFixture), + delete: jest.fn().mockResolvedValue(undefined), + retrieve: jest.fn().mockReturnValue(retrieveExchangeApiKeysResponseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts new file mode 100644 index 0000000000..0e0c63037e --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts @@ -0,0 +1,73 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { ExchangeApiKeysService } from '../exchange-api-keys.service'; +import { + enrollExchangeApiKeysCommandFixture, + enrollExchangeApiKeysResponseFixture, + retrieveExchangeApiKeysResponseFixture, + TOKEN, +} from './exchange-api-keys.fixtures'; + +describe('ExchangeApiKeysService', () => { + let service: ExchangeApiKeysService; + let reputationOracleMock: Partial; + + beforeEach(async () => { + reputationOracleMock = { + enrollExchangeApiKeys: jest.fn(), + deleteExchangeApiKeys: jest.fn(), + retrieveExchangeApiKeys: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeApiKeysService, + { provide: ReputationOracleGateway, useValue: reputationOracleMock }, + ], + }).compile(); + + service = module.get(ExchangeApiKeysService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('enroll', () => { + it('should enroll exchange API keys and return id', async () => { + ( + reputationOracleMock.enrollExchangeApiKeys as jest.Mock + ).mockResolvedValue(enrollExchangeApiKeysResponseFixture); + const result = await service.enroll(enrollExchangeApiKeysCommandFixture); + expect(reputationOracleMock.enrollExchangeApiKeys).toHaveBeenCalledWith( + enrollExchangeApiKeysCommandFixture, + ); + expect(result).toEqual(enrollExchangeApiKeysResponseFixture); + }); + }); + + describe('delete', () => { + it('should delete exchange API keys', async () => { + ( + reputationOracleMock.deleteExchangeApiKeys as jest.Mock + ).mockResolvedValue(undefined); + await service.delete(TOKEN); + expect(reputationOracleMock.deleteExchangeApiKeys).toHaveBeenCalledWith( + TOKEN, + ); + }); + }); + + describe('retrieve', () => { + it('should retrieve exchange API keys', async () => { + ( + reputationOracleMock.retrieveExchangeApiKeys as jest.Mock + ).mockResolvedValue(retrieveExchangeApiKeysResponseFixture); + const result = await service.retrieve(TOKEN); + expect(reputationOracleMock.retrieveExchangeApiKeys).toHaveBeenCalledWith( + TOKEN, + ); + expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts index 03cfa10059..9ebe0ade19 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts @@ -9,6 +9,7 @@ import { Post, Query, Request, + ForbiddenException, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RequestWithUser } from '../../common/interfaces/jwt'; @@ -46,6 +47,10 @@ export class JobAssignmentController { @Body() jobAssignmentDto: JobAssignmentDto, @Request() req: RequestWithUser, ): Promise { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } // TODO: temporal - THIRSTYFI if (jobAssignmentDto.escrow_address === 'thirstyfi-task') { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -103,6 +108,16 @@ export class JobAssignmentController { @Query() jobsAssignmentParamsDto: JobsFetchParamsDto, @Request() req: RequestWithUser, ): Promise { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + return { + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }; + } // TODO: temporal - THIRSTYFI if ( jobsAssignmentParamsDto.oracle_address === @@ -166,6 +181,10 @@ export class JobAssignmentController { @Body() dto: ResignJobDto, @Request() req: RequestWithUser, ) { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } const command = this.mapper.map(dto, ResignJobDto, ResignJobCommand); command.token = req.token; return this.service.resignJob(command); @@ -180,6 +199,10 @@ export class JobAssignmentController { @Body() dto: RefreshJobDto, @Request() req: RequestWithUser, ) { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } const command = new JobsFetchParamsCommand(); command.oracleAddress = dto.oracle_address; command.token = req.token; diff --git a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts index ae69ce4e12..f810cce1ca 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts @@ -26,6 +26,7 @@ import { TOKEN, } from './job-assignment.fixtures'; import { jobAssignmentServiceMock } from './job-assignment.service.mock'; +import { ForbiddenException } from '@nestjs/common'; const httpServiceMock = { request: jest.fn().mockImplementation((options) => { @@ -77,6 +78,7 @@ describe('JobAssignmentController', () => { const command: JobAssignmentCommand = jobAssignmentCommandFixture; await controller.assignJob(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.processJobAssignment).toHaveBeenCalledWith( command, @@ -88,32 +90,85 @@ describe('JobAssignmentController', () => { const command: JobAssignmentCommand = jobAssignmentCommandFixture; const result = await controller.assignJob(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(result).toEqual( jobAssignmentServiceMock.processJobAssignment(command), ); }); + it('should throw ForbiddenException if user is not stake eligible in assignJob', async () => { + const dto: JobAssignmentDto = jobAssignmentDtoFixture; + await expect( + controller.assignJob(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); + it('should call service processGetAssignedJobs method with proper fields set', async () => { const dto: JobsFetchParamsDto = jobsFetchParamsDtoFixture; const command: JobsFetchParamsCommand = jobsFetchParamsCommandFixture; await controller.getAssignedJobs(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.processGetAssignedJobs).toHaveBeenCalledWith( command, ); }); + it('should return empty results if user is not stake eligible in getAssignedJobs', async () => { + const dto: JobsFetchParamsDto = jobsFetchParamsDtoFixture; + const result = await controller.getAssignedJobs(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser); + expect(result).toEqual({ + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }); + }); + it('should call service refreshAssigments method with proper fields set', async () => { const dto: RefreshJobDto = refreshJobDtoFixture; await controller.refreshAssigments(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.updateAssignmentsCache).toHaveBeenCalledWith({ oracleAddress: EXCHANGE_ORACLE_ADDRESS, token: TOKEN, }); }); + + it('should throw ForbiddenException if user is not stake eligible in refreshAssigments', async () => { + const dto: RefreshJobDto = refreshJobDtoFixture; + await expect( + controller.refreshAssigments(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); + }); + + describe('resignAssigment', () => { + it('should throw ForbiddenException if user is not stake eligible in resignAssigment', async () => { + const dto = { assignment_id: '1' }; + await expect( + controller.resignAssigment( + dto as any, + { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser, + ), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); }); }); diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts index abfc928d02..843c37c38b 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts @@ -34,7 +34,7 @@ export class JobsDiscoveryController { private readonly service: JobsDiscoveryService, private readonly environmentConfigService: EnvironmentConfigService, @InjectMapper() private readonly mapper: Mapper, - ) { } + ) {} @Get('/jobs') @ApiOperation({ @@ -51,6 +51,18 @@ export class JobsDiscoveryController { HttpStatus.FORBIDDEN, ); } + + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + return { + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }; + } + // TODO: temporal - THIRSTYFI if ( jobsDiscoveryParamsDto.oracle_address === @@ -70,32 +82,33 @@ export class JobsDiscoveryController { return (data?.id ?? 0 > 0) ? { - page: 0, - page_size: 1, - total_pages: 1, - total_results: 0, - results: [], - } + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + } : { - page: 0, - page_size: 1, - total_pages: 1, - total_results: 1, - results: [ - { - chain_id: ChainId.POLYGON, - escrow_address: 'thirstyfi-task', - job_type: 'thirstyfi', - job_description: 'Check job description at https://thirsty.fi/blog/campaign-human-protocol', - reward_amount: '5 - 50', - reward_token: 'USDT', - status: JobStatus.ACTIVE, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - qualifications: [], - }, - ], - }; + page: 0, + page_size: 1, + total_pages: 1, + total_results: 1, + results: [ + { + chain_id: ChainId.POLYGON, + escrow_address: 'thirstyfi-task', + job_type: 'thirstyfi', + job_description: + 'Check job description at https://thirsty.fi/blog/campaign-human-protocol', + reward_amount: '5 - 50', + reward_token: 'USDT', + status: JobStatus.ACTIVE, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + qualifications: [], + }, + ], + }; } return { diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts index fb4e14b4bf..9b73a31176 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts @@ -14,6 +14,7 @@ import { dtoFixture, jobsDiscoveryParamsCommandFixture, responseFixture, + jobDiscoveryToken, } from './jobs-discovery.fixtures'; import { jobsDiscoveryServiceMock } from './jobs-discovery.service.mock'; @@ -73,7 +74,7 @@ describe('JobsDiscoveryController', () => { const dto = dtoFixture; const command = jobsDiscoveryParamsCommandFixture; await controller.getJobs(dto, { - user: { qualifications: [] }, + user: { qualifications: [], is_stake_eligible: true }, token: command.token, } as any); command.data.qualifications = []; @@ -90,6 +91,22 @@ describe('JobsDiscoveryController', () => { ).rejects.toThrow( new HttpException('Jobs discovery is disabled', HttpStatus.FORBIDDEN), ); + (configServiceMock as any).jobsDiscoveryFlag = true; + }); + + it('should return empty results if user is not stake eligible', async () => { + const dto = dtoFixture; + const result = await controller.getJobs(dto, { + user: { qualifications: [], is_stake_eligible: false }, + token: jobDiscoveryToken, + } as any); + expect(result).toEqual({ + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }); }); }); }); diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts index d0584d2635..038a12a021 100644 --- a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts @@ -9,7 +9,12 @@ import { Query, Request, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { RequestWithUser } from '../../common/interfaces/jwt'; import { @@ -22,6 +27,7 @@ import Environment from '../../common/utils/environment'; import { ChainId } from '@human-protocol/sdk'; @ApiTags('Oracle-Discovery') +@ApiBearerAuth() @Controller() export class OracleDiscoveryController { constructor( diff --git a/packages/apps/reputation-oracle/server/.env.example b/packages/apps/reputation-oracle/server/.env.example index c3c6a028ce..80da4aa283 100644 --- a/packages/apps/reputation-oracle/server/.env.example +++ b/packages/apps/reputation-oracle/server/.env.example @@ -97,3 +97,6 @@ NDA_URL=https://humanprotocol.org # HUMAN App secret key for auth in RepO HUMAN_APP_SECRET_KEY=sk_example_1VwUpBMO8H0v4Pmu4TPiWFEwuMguW4PkozSban4Rfbc + +# Aes +AES_ENCRYPTION_KEY=e59a0bb854ff5b338fbfc86452690be2 diff --git a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts index 8d266da004..e19787ae8b 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts @@ -1,10 +1,10 @@ +import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { ChainId, NetworkData, NETWORKS, StakingClient, } from '@human-protocol/sdk'; -import { HMToken__factory } from '@human-protocol/core/typechain-types'; import * as dotenv from 'dotenv'; import { Wallet, ethers } from 'ethers'; diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index a064f1b5c9..91bcb99048 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -14,6 +14,7 @@ import { AbuseModule } from './modules/abuse'; import { AuthModule } from './modules/auth'; import { CronJobModule } from './modules/cron-job'; import { EscrowCompletionModule } from './modules/escrow-completion'; +import { ExchangeApiKeysModule } from './modules/exchange-api-keys'; import { HealthModule } from './modules/health'; import { KycModule } from './modules/kyc'; import { NDAModule } from './modules/nda'; @@ -72,6 +73,7 @@ import Environment from './utils/environment'; CronJobModule, UserModule, NDAModule, + ExchangeApiKeysModule, EscrowCompletionModule, HealthModule, KycModule, diff --git a/packages/apps/reputation-oracle/server/src/common/constants/index.ts b/packages/apps/reputation-oracle/server/src/common/constants/index.ts index 637d783de0..2a0061d1c3 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -15,3 +15,8 @@ export const RESEND_EMAIL_VERIFICATION_PATH = export const LOGOUT_PATH = '/auth/logout'; export const BACKOFF_INTERVAL_SECONDS = 120; + +export const SUPPORTED_EXCHANGE_NAMES = ['mexc', 'gate'] as const; +export type SupportedExchange = (typeof SUPPORTED_EXCHANGE_NAMES)[number]; + +export const DEFAULT_TIMEOUT_MS = 5000; diff --git a/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts b/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts index 5408ab7dd1..c82683d25e 100644 --- a/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts +++ b/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts @@ -23,6 +23,10 @@ export class TransformInterceptor implements NestInterceptor { request.query = this.transformRequestData(request.query); } + if (request.params) { + request.params = this.transformRequestData(request.params); + } + return next.handle().pipe(map((data) => this.transformResponseData(data))); } diff --git a/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts b/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts new file mode 100644 index 0000000000..798f0f19b0 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts @@ -0,0 +1,32 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +import { + SUPPORTED_EXCHANGE_NAMES, + type SupportedExchange, +} from '@/common/constants'; + +const validExchangeNameSet = new Set( + SUPPORTED_EXCHANGE_NAMES, +); +export function isValidExchangeName(input: string): input is SupportedExchange { + return validExchangeNameSet.has(input as SupportedExchange); +} + +@ValidatorConstraint({ name: 'ExchangeName', async: false }) +export class ExchangeNameValidator implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + return isValidExchangeName(value); + } + + defaultMessage({ property }: ValidationArguments): string { + return `${property} must be one of the allowed values`; + } +} diff --git a/packages/apps/reputation-oracle/server/src/common/validators/index.ts b/packages/apps/reputation-oracle/server/src/common/validators/index.ts index e71aa847a1..cc4c0487c2 100644 --- a/packages/apps/reputation-oracle/server/src/common/validators/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/validators/index.ts @@ -8,6 +8,7 @@ import { ValidationOptions, } from 'class-validator'; +export * from './exchange'; export * from './password'; export * from './web3'; diff --git a/packages/apps/reputation-oracle/server/src/config/config.module.ts b/packages/apps/reputation-oracle/server/src/config/config.module.ts index eb3e0f4af0..1adb75e47f 100644 --- a/packages/apps/reputation-oracle/server/src/config/config.module.ts +++ b/packages/apps/reputation-oracle/server/src/config/config.module.ts @@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config'; import { AuthConfigService } from './auth-config.service'; import { DatabaseConfigService } from './database-config.service'; import { EmailConfigService } from './email-config.service'; +import { EncryptionConfigService } from './encryption-config.service'; import { HCaptchaConfigService } from './hcaptcha-config.service'; import { KycConfigService } from './kyc-config.service'; import { NDAConfigService } from './nda-config.service'; @@ -12,6 +13,7 @@ import { ReputationConfigService } from './reputation-config.service'; import { S3ConfigService } from './s3-config.service'; import { ServerConfigService } from './server-config.service'; import { SlackConfigService } from './slack-config.service'; +import { StakingConfigService } from './staking-config.service'; import { Web3ConfigService } from './web3-config.service'; @Global() @@ -21,11 +23,13 @@ import { Web3ConfigService } from './web3-config.service'; AuthConfigService, DatabaseConfigService, EmailConfigService, + EncryptionConfigService, HCaptchaConfigService, KycConfigService, NDAConfigService, PGPConfigService, ReputationConfigService, + StakingConfigService, S3ConfigService, ServerConfigService, SlackConfigService, @@ -35,11 +39,13 @@ import { Web3ConfigService } from './web3-config.service'; AuthConfigService, DatabaseConfigService, EmailConfigService, + EncryptionConfigService, HCaptchaConfigService, KycConfigService, NDAConfigService, PGPConfigService, ReputationConfigService, + StakingConfigService, S3ConfigService, ServerConfigService, SlackConfigService, diff --git a/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts b/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts new file mode 100644 index 0000000000..ff5e8feb7b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EncryptionConfigService { + constructor(private configService: ConfigService) {} + + /** + * 32-byte key for AES encrpytion + */ + get aesEncryptionKey(): string { + return this.configService.getOrThrow('AES_ENCRYPTION_KEY'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index 0df0b9fd1f..149ad5deea 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -82,10 +82,23 @@ export const envValidator = Joi.object({ KYC_BASE_URL: Joi.string().uri({ scheme: ['http', 'https'] }), // Human App HUMAN_APP_SECRET_KEY: Joi.string().required(), + // Staking configuration + STAKING_ASSET: Joi.string().description( + 'Asset symbol to check for staking (default HMT)', + ), + STAKING_MIN_THRESHOLD: Joi.number() + .min(0) + .description('Minimum asset amount to qualify as staked'), + STAKING_TIMEOUT_MS: Joi.number() + .integer() + .min(100) + .description('HTTP timeout for exchange staking checks in ms'), // Slack notifications ABUSE_SLACK_WEBHOOK_URL: Joi.string() .uri({ scheme: ['http', 'https'] }) .required(), ABUSE_SLACK_OAUTH_TOKEN: Joi.string().required(), ABUSE_SLACK_SIGNING_SECRET: Joi.string().required(), + // Encryption + AES_ENCRYPTION_KEY: Joi.string().required().length(32), }); diff --git a/packages/apps/reputation-oracle/server/src/config/index.ts b/packages/apps/reputation-oracle/server/src/config/index.ts index 2e76538048..5d1318cd56 100644 --- a/packages/apps/reputation-oracle/server/src/config/index.ts +++ b/packages/apps/reputation-oracle/server/src/config/index.ts @@ -3,11 +3,13 @@ export * from './env-schema'; export { AuthConfigService } from './auth-config.service'; export { DatabaseConfigService } from './database-config.service'; export { EmailConfigService } from './email-config.service'; +export { EncryptionConfigService } from './encryption-config.service'; export { HCaptchaConfigService } from './hcaptcha-config.service'; export { KycConfigService } from './kyc-config.service'; export { NDAConfigService } from './nda-config.service'; export { PGPConfigService } from './pgp-config.service'; export { ReputationConfigService } from './reputation-config.service'; +export { StakingConfigService } from './staking-config.service'; export { S3ConfigService } from './s3-config.service'; export { ServerConfigService } from './server-config.service'; export { Web3ConfigService, Web3Network } from './web3-config.service'; diff --git a/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts new file mode 100644 index 0000000000..174d3ed5d1 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class StakingConfigService { + constructor(private readonly configService: ConfigService) {} + + /** + * Default asset symbol to check for staking eligibility. + * Default: 'HMT' + */ + get asset(): string { + return this.configService.get('STAKING_ASSET', 'HMT'); + } + + /** + * Minimum threshold (asset units) required for staking eligibility. + * Default: 1000 + */ + get minThreshold(): number { + return Number(this.configService.get('STAKING_MIN_THRESHOLD')) || 1000; + } + + /** + * Optional per-exchange HTTP timeout, in milliseconds. + * Default: 2000 + */ + get timeoutMs(): number { + return Number(this.configService.get('STAKING_TIMEOUT_MS')) || 2000; + } + + /** + * Feature flag to enable/disable staking eligibility enforcement. + * When disabled, eligibility will be treated as true unconditionally. + * Default: false + */ + get eligibilityEnabled(): boolean { + return ( + this.configService.get('STAKING_ELIGIBILITY_ENABLED', 'false') === + 'true' + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/database/database.module.ts b/packages/apps/reputation-oracle/server/src/database/database.module.ts index 093c5d7abf..c8bb6dc8f3 100644 --- a/packages/apps/reputation-oracle/server/src/database/database.module.ts +++ b/packages/apps/reputation-oracle/server/src/database/database.module.ts @@ -13,6 +13,7 @@ import { TokenEntity } from '@/modules/auth/token.entity'; import { CronJobEntity } from '@/modules/cron-job/cron-job.entity'; import { EscrowCompletionEntity } from '@/modules/escrow-completion/escrow-completion.entity'; import { EscrowPayoutsBatchEntity } from '@/modules/escrow-completion/escrow-payouts-batch.entity'; +import { ExchangeApiKeyEntity } from '@/modules/exchange-api-keys'; import { KycEntity } from '@/modules/kyc/kyc.entity'; import { QualificationEntity } from '@/modules/qualification/qualification.entity'; import { UserQualificationEntity } from '@/modules/qualification/user-qualification.entity'; @@ -71,6 +72,7 @@ import { TypeOrmLoggerModule, TypeOrmLoggerService } from './typeorm'; OutgoingWebhookEntity, EscrowCompletionEntity, EscrowPayoutsBatchEntity, + ExchangeApiKeyEntity, ReputationEntity, TokenEntity, UserEntity, diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts new file mode 100644 index 0000000000..3b5f7f91bd --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExchangeApiKeys1761653939799 implements MigrationInterface { + name = 'ExchangeApiKeys1761653939799'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "hmt"."exchange_api_keys" ("id" SERIAL NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, "exchange_name" character varying(20) NOT NULL, "api_key" character varying(1000) NOT NULL, "secret_key" character varying(10000) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "PK_3751a8a0ef5354b32b06ea43983" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_96ee74195b058a1b55afc49f67" ON "hmt"."exchange_api_keys" ("user_id") `, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."exchange_api_keys" ADD CONSTRAINT "FK_96ee74195b058a1b55afc49f673" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."exchange_api_keys" DROP CONSTRAINT "FK_96ee74195b058a1b55afc49f673"`, + ); + await queryRunner.query( + `DROP INDEX "hmt"."IDX_96ee74195b058a1b55afc49f67"`, + ); + await queryRunner.query(`DROP TABLE "hmt"."exchange_api_keys"`); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts index f613211882..bb6a77d917 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts @@ -4,6 +4,8 @@ import { JwtModule } from '@nestjs/jwt'; import { AuthConfigService } from '@/config'; import { HCaptchaModule } from '@/integrations/hcaptcha'; import { EmailModule } from '@/modules/email'; +import { ExchangeModule } from '@/modules/exchange'; +import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; import { UserModule } from '@/modules/user'; import { Web3Module } from '@/modules/web3'; @@ -27,6 +29,8 @@ import { TokenRepository } from './token.repository'; }), Web3Module, HCaptchaModule, + ExchangeModule, + ExchangeApiKeysModule, EmailModule, ], providers: [JwtHttpStrategy, AuthService, TokenRepository], diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index 8fe72fc2a0..6892757060 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -11,13 +11,18 @@ import { SignatureType, UserStatus, UserRole } from '@/common/enums'; import { AuthConfigService, NDAConfigService, + StakingConfigService, ServerConfigService, Web3ConfigService, } from '@/config'; import { EmailAction, EmailService } from '@/modules/email'; +import { ExchangeClientFactory } from '@/modules/exchange'; +import type { ExchangeClient } from '@/modules/exchange/types'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { SiteKeyRepository } from '@/modules/user'; import { UserEntity, UserRepository, UserService } from '@/modules/user'; import { generateOperator, generateWorkerUser } from '@/modules/user/fixtures'; +import { Web3Service } from '@/modules/web3'; import { mockWeb3ConfigService } from '@/modules/web3/fixtures'; import * as secutiryUtils from '@/utils/security'; import * as web3Utils from '@/utils/web3'; @@ -28,6 +33,7 @@ import * as AuthErrors from './auth.error'; import { AuthService } from './auth.service'; import { TokenEntity, TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; +import { generateExchangeApiKeysData } from '../exchange-api-keys/fixtures'; const mockKVStoreUtils = jest.mocked(KVStoreUtils); @@ -41,6 +47,12 @@ const mockAuthConfigService: Omit = { forgotPasswordExpiresIn: 86400000, humanAppSecretKey: faker.string.alphanumeric({ length: 42 }), }; +const mockStakingConfigService: Omit = { + eligibilityEnabled: true, + minThreshold: 100, + asset: 'ETH', + timeoutMs: 2000, +}; const mockEmailService = createMock(); @@ -56,6 +68,9 @@ const mockSiteKeyRepository = createMock(); const mockTokenRepository = createMock(); const mockUserRepository = createMock(); const mockUserService = createMock(); +const mockExchangeApiKeysService = createMock(); +const mockExchangeClientFactory = createMock(); +const mockWeb3Service = createMock(); describe('AuthService', () => { let service: AuthService; @@ -85,6 +100,13 @@ describe('AuthService', () => { { provide: UserRepository, useValue: mockUserRepository }, { provide: UserService, useValue: mockUserService }, { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, + { + provide: ExchangeApiKeysService, + useValue: mockExchangeApiKeysService, + }, + { provide: ExchangeClientFactory, useValue: mockExchangeClientFactory }, + { provide: StakingConfigService, useValue: mockStakingConfigService }, + { provide: Web3Service, useValue: mockWeb3Service }, ], }).compile(); @@ -626,6 +648,7 @@ describe('AuthService', () => { wallet_address: user.evmAddress, role: user.role, kyc_status: user.kyc?.status, + is_stake_eligible: true, nda_signed: user.ndaSignedUrl === mockNdaConfigService.latestNdaUrl, reputation_network: mockWeb3ConfigService.operatorAddress, qualifications: user.userQualifications @@ -635,6 +658,11 @@ describe('AuthService', () => { : [], }; + const spyOncheckStakeEligible = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(service as any, 'checkStakeEligible') + .mockImplementation(); + spyOncheckStakeEligible.mockResolvedValueOnce(true); const spyOnGenerateTokens = jest .spyOn(service, 'generateTokens') .mockImplementation(); @@ -651,10 +679,141 @@ describe('AuthService', () => { expectedJwtPayload, ); + expect(spyOncheckStakeEligible).toHaveBeenCalledTimes(1); + + spyOncheckStakeEligible.mockRestore(); spyOnGenerateTokens.mockRestore(); }); }); + describe('checkStakeEligible', () => { + it('returns true when feature flag disabled', async () => { + const user = generateWorkerUser(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = false; + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeApiKeysService.retrieve).not.toHaveBeenCalled(); + expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); + }); + + it('returns true when exchange balance meets threshold (no on-chain call)', async () => { + const user = generateWorkerUser(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( + generateExchangeApiKeysData(), + ); + + const mockClient = { + getAccountBalance: jest.fn().mockResolvedValue(1500), + }; + mockExchangeClientFactory.create.mockResolvedValueOnce( + mockClient as unknown as ExchangeClient, + ); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); + }); + + it('returns true when exchange balance below threshold but on-chain makes up the difference', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( + generateExchangeApiKeysData(), + ); + + const mockClient = { + getAccountBalance: jest.fn().mockResolvedValue(400), + }; + mockExchangeClientFactory.create.mockResolvedValueOnce( + mockClient as unknown as ExchangeClient, + ); + mockWeb3Service.getStakedBalance.mockResolvedValueOnce(600); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledWith( + user.evmAddress, + ); + }); + + it('returns false when no exchange keys and on-chain stake below threshold', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(null); + mockWeb3Service.getStakedBalance.mockResolvedValueOnce(500); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(false); + expect(mockExchangeClientFactory.create).not.toHaveBeenCalled(); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + }); + + it('continues on exchange error and returns based on on-chain stake', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( + generateExchangeApiKeysData(), + ); + + const mockClient = { + getAccountBalance: jest.fn().mockRejectedValue(new Error('network')), + }; + mockExchangeClientFactory.create.mockResolvedValueOnce( + mockClient as unknown as ExchangeClient, + ); + mockWeb3Service.getStakedBalance.mockResolvedValueOnce(1200); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + }); + }); + describe('web3Auth', () => { it('should generate jwt payload for operator', async () => { const operator = generateOperator(); diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index b116b089ae..f1f6a0e502 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -2,15 +2,19 @@ import { KVStoreKeys, KVStoreUtils, Role } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { SupportedExchange } from '@/common/constants'; import { SignatureType, UserRole, UserStatus } from '@/common/enums'; import { AuthConfigService, NDAConfigService, ServerConfigService, + StakingConfigService, Web3ConfigService, } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { OperatorStatus, SiteKeyRepository, @@ -21,6 +25,7 @@ import { type OperatorUserEntity, type Web2UserEntity, } from '@/modules/user'; +import { Web3Service } from '@/modules/web3'; import * as httpUtils from '@/utils/http'; import * as securityUtils from '@/utils/security'; import * as web3Utils from '@/utils/web3'; @@ -55,7 +60,11 @@ export class AuthService { private readonly tokenRepository: TokenRepository, private readonly userRepository: UserRepository, private readonly userService: UserService, + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeClientFactory: ExchangeClientFactory, + private readonly stakingConfigService: StakingConfigService, private readonly web3ConfigService: Web3ConfigService, + private readonly web3Service: Web3Service, ) {} async signup(email: string, password: string): Promise { @@ -244,6 +253,8 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } + const stakeEligible = await this.checkStakeEligible(userEntity); + const jwtPayload = { email: userEntity.email, status: userEntity.status, @@ -253,6 +264,7 @@ export class AuthService { kyc_status: userEntity.kyc?.status, nda_signed: userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, + is_stake_eligible: stakeEligible, reputation_network: this.web3ConfigService.operatorAddress, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( @@ -265,6 +277,51 @@ export class AuthService { return this.generateTokens(userEntity.id, jwtPayload); } + private async checkStakeEligible( + userEntity: Web2UserEntity | UserEntity, + ): Promise { + if (!this.stakingConfigService.eligibilityEnabled) return true; + + const apiKeys = await this.exchangeApiKeysService.retrieve(userEntity.id); + + let inspectedStakeAmount = 0; + + if (apiKeys) { + try { + const client = await this.exchangeClientFactory.create( + apiKeys.exchangeName as SupportedExchange, + { + apiKey: apiKeys.apiKey, + secretKey: apiKeys.secretKey, + }, + { timeoutMs: this.stakingConfigService.timeoutMs }, + ); + const exchangeBalance = await client.getAccountBalance( + this.stakingConfigService.asset, + ); + inspectedStakeAmount += exchangeBalance; + } catch (err) { + this.logger.warn('Failed to query exchange balance; continuing', { + userId: userEntity.id, + exchangeName: apiKeys.exchangeName, + error: err, + }); + } + } + + if ( + inspectedStakeAmount < this.stakingConfigService.minThreshold && + userEntity.evmAddress + ) { + const onChainStake = await this.web3Service.getStakedBalance( + userEntity.evmAddress, + ); + inspectedStakeAmount += onChainStake; + } + + return inspectedStakeAmount >= this.stakingConfigService.minThreshold; + } + async web3Auth(userEntity: OperatorUserEntity): Promise { /** * NOTE diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts new file mode 100644 index 0000000000..e9f856a784 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts @@ -0,0 +1,85 @@ +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; + +import { EncryptionConfigService } from '@/config'; + +import { AesEncryptionService } from './aes-encryption.service'; +import { generateAesEncryptionKey } from './fixtures'; + +const HEX_FORMAT_REGEX = /[0-9a-f]+/; + +const mockGetAesEncryptionKey = jest.fn(); + +const mockEncryptionConfigService: Omit< + EncryptionConfigService, + 'configService' +> = { + get aesEncryptionKey() { + return mockGetAesEncryptionKey(); + }, +}; + +describe('AesEncryptionService', () => { + let aesEncryptionService: AesEncryptionService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: EncryptionConfigService, + useValue: mockEncryptionConfigService, + }, + AesEncryptionService, + ], + }).compile(); + + aesEncryptionService = + moduleRef.get(AesEncryptionService); + }); + + beforeEach(() => { + mockGetAesEncryptionKey.mockReturnValue(generateAesEncryptionKey()); + }); + + it('should be defined', () => { + expect(aesEncryptionService).toBeDefined(); + }); + + it('should encrypt data and return envelope string', async () => { + const data = faker.lorem.lines(); + + const envelope = await aesEncryptionService.encrypt(Buffer.from(data)); + + expect(typeof envelope).toBe('string'); + + const [authTag, encrypted, iv] = envelope.split(':'); + + expect(authTag).toMatch(HEX_FORMAT_REGEX); + expect(authTag).toHaveLength(24); + + expect(encrypted).toMatch(HEX_FORMAT_REGEX); + + expect(iv).toHaveLength(32); + expect(iv).toMatch(HEX_FORMAT_REGEX); + }); + + it('should decrypt data encrypted by itslef', async () => { + const data = faker.lorem.lines(); + + const encrypted = await aesEncryptionService.encrypt(Buffer.from(data)); + const decrypted = await aesEncryptionService.decrypt(encrypted); + + expect(decrypted.toString()).toBe(data); + }); + + it('should fail to decrypt if different encryption key used', async () => { + mockGetAesEncryptionKey.mockReturnValueOnce(generateAesEncryptionKey()); + mockGetAesEncryptionKey.mockReturnValueOnce(generateAesEncryptionKey()); + + const data = faker.lorem.lines(); + + const encrypted = await aesEncryptionService.encrypt(Buffer.from(data)); + + await expect(aesEncryptionService.decrypt(encrypted)).rejects.toThrow(); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts new file mode 100644 index 0000000000..d42ddbfa30 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts @@ -0,0 +1,100 @@ +import crypto from 'crypto'; + +import { Injectable } from '@nestjs/common'; + +import { EncryptionConfigService } from '@/config'; +import logger from '@/logger'; + +const ALGORITHM = 'aes-256-gcm'; +const GCM_IV_LENGTH_BYTES = 12; + +/** + * Security note: + * - Best practice is to use one encryption key per purpose (e.g., exchange API keys, PII, etc.). + * - At the moment, we only encrypt exchange API keys and therefore use a single key + * provided by EncryptionConfigService.aesEncryptionKey. + * - If/when we start encrypting different kinds of data, we should switch to per-purpose keys: + * - add dedicated config entries for each purpose (e.g., ENCRYPTION_USER_EXCHANGE_API_KEY, ...), + * - update this service so encrypt/decrypt accept an explicit encryptionKey parameter, + * - and ensure callers pass the correct key for the data they are encrypting/decrypting. + */ + +type EncryptionOutput = { + encrypted: Buffer; + iv: Buffer; + authTag: Buffer; +}; + +@Injectable() +export class AesEncryptionService { + private readonly logger = logger.child({ + context: AesEncryptionService.name, + }); + + constructor( + private readonly encryptionConfigService: EncryptionConfigService, + ) {} + + private composeEnvelopeString({ + encrypted, + authTag, + iv, + }: EncryptionOutput): string { + return `${iv.toString('hex')}:${encrypted.toString('hex')}:${authTag.toString('hex')}`; + } + + private parseEnvelopeString(envelope: string): EncryptionOutput { + const [iv, encrypted, authTag] = envelope.split(':'); + + if (!iv || !encrypted || !authTag) { + throw new Error('Invalid AES envelope'); + } + + return { + iv: Buffer.from(iv, 'hex'), + encrypted: Buffer.from(encrypted, 'hex'), + authTag: Buffer.from(authTag, 'hex'), + }; + } + + async encrypt(data: Buffer): Promise { + const encryptionKey = this.encryptionConfigService.aesEncryptionKey; + + const iv = crypto.randomBytes(GCM_IV_LENGTH_BYTES); + + const cipher = crypto.createCipheriv( + ALGORITHM, + Buffer.from(encryptionKey), + iv, + ); + + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return this.composeEnvelopeString({ + iv, + encrypted, + authTag, + }); + } + + async decrypt(envelope: string): Promise { + const { iv, encrypted, authTag } = this.parseEnvelopeString(envelope); + + const encryptionKey = this.encryptionConfigService.aesEncryptionKey; + + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(encryptionKey), + iv, + ); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decrypted; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts index 623c981886..6583f9d356 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { Web3Module } from '@/modules/web3'; +import { AesEncryptionService } from './aes-encryption.service'; import { PgpEncryptionService } from './pgp-encryption.service'; @Module({ imports: [Web3Module], - providers: [PgpEncryptionService], - exports: [PgpEncryptionService], + providers: [AesEncryptionService, PgpEncryptionService], + exports: [AesEncryptionService, PgpEncryptionService], }) export class EncryptionModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts new file mode 100644 index 0000000000..656d5464ac --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -0,0 +1,14 @@ +import { faker } from '@faker-js/faker'; + +export const mockEncryptionConfigService = { + // 32-byte key for AES-256-GCM tests + aesEncryptionKey: generateAesEncryptionKey(), +}; + +/** + * Generates random key for AES encryption + * with the key length .expected by the app + */ +export function generateAesEncryptionKey(): string { + return faker.string.sample(32); +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts new file mode 100644 index 0000000000..5a9252614e --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, Index, ManyToOne } from 'typeorm'; + +import { DATABASE_SCHEMA_NAME } from '@/common/constants'; +import { BaseEntity } from '@/database'; +import type { UserEntity } from '@/modules/user'; + +@Entity({ schema: DATABASE_SCHEMA_NAME, name: 'exchange_api_keys' }) +@Index(['userId'], { unique: true }) +export class ExchangeApiKeyEntity extends BaseEntity { + @Column('varchar', { length: 20 }) + exchangeName: string; + + @Column('varchar', { length: 1000 }) + apiKey: string; + + @Column('varchar', { length: 10000 }) + secretKey: string; + + @ManyToOne('UserEntity', { persistence: false, onDelete: 'CASCADE' }) + user?: UserEntity; + + @Column() + userId: number; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts new file mode 100644 index 0000000000..5648537f40 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,128 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + Param, + Post, + Req, + UseFilters, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import type { RequestWithUser } from '@/common/types'; +import Environment from '@/utils/environment'; + +import { + ExchangeNameParamDto, + EnrollExchangeApiKeysDto, + EnrollExchangeApiKeysResponseDto, + EnrolledApiKeyDto, +} from './exchange-api-keys.dto'; +import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; +import { ExchangeApiKeyNotFoundError } from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { ExchangeApiKeysService } from './exchange-api-keys.service'; + +@ApiTags('Exchange API Keys') +@ApiBearerAuth() +@UseFilters(ExchangeApiKeysControllerErrorsFilter) +@Controller('exchange-api-keys') +export class ExchangeApiKeysController { + constructor( + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + ) {} + + @ApiOperation({ + summary: 'Retrieve enrolled exchange with api key', + description: 'Returns the enrolled api key for exchange w/o secret key', + }) + @ApiResponse({ + status: 200, + type: EnrolledApiKeyDto, + }) + @Get('/') + async retrieveEnrolledApiKeys( + @Req() request: RequestWithUser, + ): Promise { + const userId = request.user.id; + + const apiKey = await this.exchangeApiKeysService.retrieve(userId); + if (!apiKey) { + throw new ExchangeApiKeyNotFoundError(userId); + } + + return { + exchangeName: apiKey.exchangeName, + apiKey: apiKey.apiKey, + }; + } + + @ApiOperation({ + summary: 'Enroll API keys for exchange', + description: + 'Enrolls API keys for provided exchange. If keys already exist for exchange - updates them', + }) + @ApiResponse({ + status: 200, + description: 'Exchange API keys enrolled', + type: EnrollExchangeApiKeysResponseDto, + }) + @ApiBody({ type: EnrollExchangeApiKeysDto }) + @HttpCode(200) + @Post('/:exchange_name') + async enroll( + @Req() request: RequestWithUser, + @Param() params: ExchangeNameParamDto, + @Body() data: EnrollExchangeApiKeysDto, + ): Promise { + const key = await this.exchangeApiKeysService.enroll({ + userId: request.user.id, + exchangeName: params.exchangeName, + apiKey: data.apiKey, + secretKey: data.secretKey, + }); + + return { id: key.id }; + } + + @ApiOperation({ + summary: 'Delete API keys', + }) + @ApiResponse({ + status: 204, + description: 'Exchange API keys deleted', + }) + @HttpCode(204) + @Delete('/') + async delete(@Req() request: RequestWithUser): Promise { + await this.exchangeApiKeysRepository.deleteByUser(request.user.id); + } + + @ApiOperation({ + summary: 'Retreive API keys for exchange', + description: + 'This functionality is purely for dev solely and works only in non-production environments', + }) + @Get('/exchange') + async retrieve(@Req() request: RequestWithUser): Promise { + if (!Environment.isDevelopment()) { + throw new ForbiddenException(); + } + + const apiKey = await this.exchangeApiKeysService.retrieve(request.user.id); + if (!apiKey) { + throw new ExchangeApiKeyNotFoundError(request.user.id); + } + return apiKey; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts new file mode 100644 index 0000000000..220f04c953 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength, Validate } from 'class-validator'; + +import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; +import { ExchangeNameValidator } from '@/common/validators'; + +export class EnrollExchangeApiKeysDto { + @ApiProperty({ name: 'api_key' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + apiKey: string; + + @ApiProperty({ name: 'secret_key' }) + @IsString() + @IsNotEmpty() + @MaxLength(5000) + secretKey: string; +} + +export class ExchangeNameParamDto { + @ApiProperty({ + name: 'exchange_name', + enum: SUPPORTED_EXCHANGE_NAMES, + }) + @Validate(ExchangeNameValidator) + exchangeName: string; +} + +export class EnrollExchangeApiKeysResponseDto { + @ApiProperty() + id: number; +} + +export class EnrolledApiKeyDto { + @ApiProperty({ name: 'exchange_name' }) + exchangeName: string; + + @ApiProperty({ name: 'api_key' }) + apiKey: string; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts new file mode 100644 index 0000000000..bb21e69d46 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -0,0 +1,58 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import logger from '@/logger'; +import { UserNotFoundError } from '@/modules/user'; + +import { + ExchangeApiKeyNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, +} from './exchange-api-keys.errors'; +import { ExchangeApiClientError } from '../exchange/errors'; + +@Catch( + UserNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, + ExchangeApiKeyNotFoundError, +) +export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { + private readonly logger = logger.child({ + context: ExchangeApiKeysControllerErrorsFilter.name, + }); + + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + let status = HttpStatus.INTERNAL_SERVER_ERROR; + + if (exception instanceof UserNotFoundError) { + status = HttpStatus.UNPROCESSABLE_ENTITY; + } else if ( + exception instanceof IncompleteKeySuppliedError || + exception instanceof KeyAuthorizationError || + exception instanceof ActiveExchangeApiKeyExistsError + ) { + status = HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception instanceof ExchangeApiClientError) { + status = HttpStatus.SERVICE_UNAVAILABLE; + } else if (exception instanceof ExchangeApiKeyNotFoundError) { + status = HttpStatus.NOT_FOUND; + } + + return response.status(status).json({ + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts new file mode 100644 index 0000000000..e45850a31c --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts @@ -0,0 +1,28 @@ +import { BaseError } from '@/common/errors/base'; + +export class ExchangeApiKeyNotFoundError extends BaseError { + constructor(readonly userId: number) { + super('Exchange API key not found'); + } +} + +export class IncompleteKeySuppliedError extends BaseError { + constructor(readonly exchangeName: string) { + super('Incomplete credentials supplied for exchange'); + } +} + +export class KeyAuthorizationError extends BaseError { + constructor(readonly exchangeName: string) { + super("Provided API key can't be authorized on exchange"); + } +} + +export class ActiveExchangeApiKeyExistsError extends BaseError { + constructor( + readonly userId: number, + readonly exchangeName: string, + ) { + super('User already has an active exchange API key'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts new file mode 100644 index 0000000000..8f975d6914 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { EncryptionModule } from '@/modules/encryption'; +import { ExchangeModule } from '@/modules/exchange/exchange.module'; + +import { ExchangeApiKeysController } from './exchange-api-keys.controller'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { ExchangeApiKeysService } from './exchange-api-keys.service'; +import { UserModule } from '../user'; + +@Module({ + imports: [ExchangeModule, EncryptionModule, UserModule], + providers: [ExchangeApiKeysRepository, ExchangeApiKeysService], + controllers: [ExchangeApiKeysController], + exports: [ExchangeApiKeysRepository, ExchangeApiKeysService], +}) +export class ExchangeApiKeysModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts new file mode 100644 index 0000000000..ea0d264915 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { BaseRepository } from '@/database'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; + +@Injectable() +export class ExchangeApiKeysRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(ExchangeApiKeyEntity, dataSource); + } + + async findOneByUserId(userId: number): Promise { + if (!userId) { + throw new Error('Invalid arguments'); + } + return this.findOne({ + where: { userId }, + }); + } + + async deleteByUser(userId: number): Promise { + if (!userId) { + throw new Error('userId is required'); + } + + await this.delete({ userId }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts new file mode 100644 index 0000000000..5f26db89b9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts @@ -0,0 +1,212 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { EncryptionConfigService } from '@/config/encryption-config.service'; +import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; +import { mockEncryptionConfigService } from '@/modules/encryption/fixtures'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; + +// eslint-disable-next-line import/order +import { ExchangeApiKeysService } from './exchange-api-keys.service'; +import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { + ActiveExchangeApiKeyExistsError, + KeyAuthorizationError, +} from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { + generateExchangeApiKey, + generateExchangeApiKeysData, +} from './fixtures'; +import { ExchangeClient } from '../exchange/types'; + +const mockUserRepository = createMock(); +const mockExchangeApiKeysRepository = createMock(); +const mockExchangeClient = createMock(); + +describe('ExchangeApiKeysService', () => { + let exchangeApiKeysService: ExchangeApiKeysService; + let aesEncryptionService: AesEncryptionService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeApiKeysService, + AesEncryptionService, + { provide: UserRepository, useValue: mockUserRepository }, + { + provide: ExchangeApiKeysRepository, + useValue: mockExchangeApiKeysRepository, + }, + { + provide: EncryptionConfigService, + useValue: mockEncryptionConfigService, + }, + { + provide: ExchangeClientFactory, + useValue: { create: () => mockExchangeClient }, + }, + ], + }).compile(); + + exchangeApiKeysService = module.get( + ExchangeApiKeysService, + ); + aesEncryptionService = + module.get(AesEncryptionService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(exchangeApiKeysService).toBeDefined(); + }); + + describe('enroll', () => { + it.each([ + Object.assign(generateExchangeApiKeysData(), { userId: '' }), + Object.assign(generateExchangeApiKeysData(), { apiKey: '' }), + Object.assign(generateExchangeApiKeysData(), { secretKey: '' }), + ])('should throw if required param is missing [%#]', async (input) => { + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError.constructor).toBe(Error); + expect(thrownError.message).toBe('Invalid arguments'); + }); + + it('should throw if not supported exchange name provided', async () => { + const input = generateExchangeApiKeysData(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (input as any).exchangeName = input.exchangeName.toUpperCase(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError.constructor).toBe(Error); + expect(thrownError.message).toBe('Exchange name is not valid'); + }); + + it('should throw if provided keys do not have required access', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(false); + + const input = generateExchangeApiKeysData(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(KeyAuthorizationError); + expect(thrownError.exchangeName).toBe(input.exchangeName); + }); + + it('should throw if user already has active keys', async () => { + const input = generateExchangeApiKeysData(); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce( + generateExchangeApiKey(), + ); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(ActiveExchangeApiKeyExistsError); + }); + + it('should throw if user not exists', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + + mockUserRepository.findOneById.mockResolvedValueOnce(null); + + const input = generateExchangeApiKeysData(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(UserNotFoundError); + }); + + it('should insert encrypted keys if data is valid', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + mockUserRepository.findOneById.mockResolvedValueOnce({ + id: 1, + } as UserEntity); + + const input = generateExchangeApiKeysData(); + + const entity = await exchangeApiKeysService.enroll(input); + + expect(entity.userId).toBe(input.userId); + expect(entity.exchangeName).toBe(input.exchangeName); + expect(entity.apiKey).not.toBe(input.apiKey); + expect(entity.secretKey).not.toBe(input.secretKey); + + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + aesEncryptionService.decrypt(entity.apiKey), + aesEncryptionService.decrypt(entity.secretKey), + ]); + + expect(decryptedApiKey.toString()).toBe(input.apiKey); + expect(decryptedSecretKey.toString()).toBe(input.secretKey); + }); + }); + + describe('retrieve', () => { + it('should return null if key not found for the user', async () => { + const { userId } = generateExchangeApiKeysData(); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + + const result = await exchangeApiKeysService.retrieve(userId); + expect(result).toBeNull(); + }); + + it('should return decrypted keys', async () => { + const { userId, exchangeName, apiKey, secretKey } = + generateExchangeApiKeysData(); + + const [encryptedApiKey, encryptedSecretKey] = await Promise.all([ + aesEncryptionService.encrypt(Buffer.from(apiKey)), + aesEncryptionService.encrypt(Buffer.from(secretKey)), + ]); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce({ + exchangeName, + apiKey: encryptedApiKey, + secretKey: encryptedSecretKey, + } as ExchangeApiKeyEntity); + + const result = await exchangeApiKeysService.retrieve(userId); + + expect(result).not.toBeNull(); + expect(result!.apiKey).toBe(apiKey); + expect(result!.secretKey).toBe(secretKey); + expect( + mockExchangeApiKeysRepository.findOneByUserId, + ).toHaveBeenCalledWith(userId); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts new file mode 100644 index 0000000000..f7d785a272 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; + +import { isValidExchangeName } from '@/common/validators/exchange'; +import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { UserNotFoundError, UserRepository } from '@/modules/user'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, +} from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; + +@Injectable() +export class ExchangeApiKeysService { + constructor( + private readonly aesEncryptionService: AesEncryptionService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + private readonly exchangeClientFactory: ExchangeClientFactory, + private readonly userRepository: UserRepository, + ) {} + + async enroll(input: { + userId: number; + exchangeName: string; + apiKey: string; + secretKey: string; + }): Promise { + const { userId, exchangeName, apiKey, secretKey } = input; + + if (!userId || !apiKey || !secretKey) { + throw new Error('Invalid arguments'); + } + + if (!isValidExchangeName(exchangeName)) { + throw new Error('Exchange name is not valid'); + } + + const currentKeys = + await this.exchangeApiKeysRepository.findOneByUserId(userId); + if (currentKeys) { + throw new ActiveExchangeApiKeyExistsError(userId, exchangeName); + } + + const client = await this.exchangeClientFactory.create(exchangeName, { + apiKey, + secretKey, + }); + const hasRequiredAccess = await client.checkRequiredAccess(); + if (!hasRequiredAccess) { + throw new KeyAuthorizationError(exchangeName); + } + + const user = await this.userRepository.findOneById(userId); + if (!user) { + throw new UserNotFoundError(userId); + } + + const enrolledKey = new ExchangeApiKeyEntity(); + enrolledKey.userId = userId; + enrolledKey.exchangeName = exchangeName; + + const [encryptedApiKey, encryptedSecretKey] = await Promise.all([ + this.aesEncryptionService.encrypt(Buffer.from(apiKey)), + this.aesEncryptionService.encrypt(Buffer.from(secretKey)), + ]); + enrolledKey.apiKey = encryptedApiKey; + enrolledKey.secretKey = encryptedSecretKey; + await this.exchangeApiKeysRepository.createUnique(enrolledKey); + + return enrolledKey; + } + + async retrieve(userId: number): Promise<{ + exchangeName: string; + apiKey: string; + secretKey: string; + } | null> { + const entity = await this.exchangeApiKeysRepository.findOneByUserId(userId); + if (!entity) { + return null; + } + + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + this.aesEncryptionService.decrypt(entity.apiKey), + this.aesEncryptionService.decrypt(entity.secretKey), + ]); + + return { + exchangeName: entity.exchangeName, + apiKey: decryptedApiKey.toString(), + secretKey: decryptedSecretKey.toString(), + }; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts new file mode 100644 index 0000000000..8597aa47fc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker'; + +import { generateExchangeName } from '@/modules/exchange/fixtures'; + +import { ExchangeApiKeyEntity } from '../exchange-api-key.entity'; + +export function generateExchangeApiKeysData() { + return { + userId: faker.number.int(), + exchangeName: generateExchangeName(), + apiKey: faker.string.sample(), + secretKey: faker.string.sample(), + }; +} + +export function generateExchangeApiKey(): ExchangeApiKeyEntity { + const entity = { + id: faker.number.int(), + ...generateExchangeApiKeysData(), + createdAt: faker.date.recent(), + updatedAt: new Date(), + }; + + return entity as ExchangeApiKeyEntity; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts new file mode 100644 index 0000000000..2736f37d6b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts @@ -0,0 +1,8 @@ +export { ExchangeApiKeysModule } from './exchange-api-keys.module'; +export { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +export { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +export { ExchangeApiKeysService } from './exchange-api-keys.service'; +export { + ExchangeApiKeyNotFoundError, + KeyAuthorizationError, +} from './exchange-api-keys.errors'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts new file mode 100644 index 0000000000..9617016bd6 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts @@ -0,0 +1,3 @@ +import { BaseError } from '@/common/errors/base'; + +export class ExchangeApiClientError extends BaseError {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts new file mode 100644 index 0000000000..035c2b0ef2 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; + +import type { SupportedExchange } from '@/common/constants'; + +import { GateExchangeClient } from './gate-exchange.client'; +import { MexcExchangeClient } from './mexc-exchange.client'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; + +@Injectable() +export class ExchangeClientFactory { + async create( + exchange: SupportedExchange, + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ): Promise { + switch (exchange) { + case 'mexc': { + return new MexcExchangeClient(creds, options); + } + case 'gate': { + return new GateExchangeClient(creds, options); + } + default: + throw new Error(`Unsupported exchange: ${exchange}`); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts new file mode 100644 index 0000000000..ddc664fdc8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { ExchangeClientFactory } from './exchange-client.factory'; + +@Module({ + providers: [ExchangeClientFactory], + exports: [ExchangeClientFactory], +}) +export class ExchangeModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts new file mode 100644 index 0000000000..bb56ea256b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts @@ -0,0 +1,32 @@ +import { faker } from '@faker-js/faker'; + +import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; + +export function generateGateAccountBalance(tokens: string[] = []) { + if (tokens.length === 0) { + throw new Error('At least one token must be specified'); + } + return tokens.map((token) => ({ + currency: token, + available: faker.finance.amount(), + locked: faker.finance.amount(), + freeze: faker.finance.amount(), + })); +} + +export function generateMexcAccountBalance(tokens: string[] = []) { + if (tokens.length === 0) { + throw new Error('At least one token must be specified'); + } + return { + balances: tokens.map((token) => ({ + asset: token, + free: faker.finance.amount(), + locked: faker.finance.amount(), + })), + }; +} + +export function generateExchangeName() { + return faker.helpers.arrayElement(SUPPORTED_EXCHANGE_NAMES); +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts new file mode 100644 index 0000000000..40165d35a9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts @@ -0,0 +1 @@ +export * from './exchange'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts new file mode 100644 index 0000000000..3c35786cac --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts @@ -0,0 +1,187 @@ +jest.mock('@/logger'); + +import crypto from 'crypto'; + +import { faker } from '@faker-js/faker'; +import nock from 'nock'; + +import { ExchangeApiClientError } from './errors'; +import { generateGateAccountBalance } from './fixtures'; +import { + DEVELOP_GATE_API_BASE_URL, + GateExchangeClient, +} from './gate-exchange.client'; + +describe('GateExchangeClient', () => { + afterAll(() => { + nock.restore(); + }); + + afterEach(() => { + jest.resetAllMocks(); + nock.cleanAll(); + }); + + describe('signGateRequest', () => { + it('returns the expected signature for known input', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const method = faker.string.sample(); + const path = faker.string.sample(); + const query = faker.string.sample(); + const body = faker.string.sample(); + const secret = faker.string.sample(); + const ts = faker.number.int().toString(); + + const client = new GateExchangeClient({ apiKey, secretKey }); + + const bodyHash = crypto.createHash('sha512').update(body).digest('hex'); + const payload = [method, path, query, bodyHash, ts].join('\n'); + const expectedSignature = crypto + .createHmac('sha512', secret) + .update(payload) + .digest('hex'); + + const signature = client['signGateRequest']( + method, + path, + query, + body, + secret, + ts, + ); + expect(signature).toBe(expectedSignature); + }); + }); + + describe('constructor', () => { + it('throws if credentials are missing', () => { + expect( + () => new GateExchangeClient({ apiKey: '', secretKey: '' }), + ).toThrow(ExchangeApiClientError); + }); + + it('sets fields correctly', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient( + { apiKey, secretKey }, + { timeoutMs: 1234 }, + ); + expect(client).toBeDefined(); + expect(client['apiKey']).toBe(apiKey); + expect(client['secretKey']).toBe(secretKey); + expect(client['timeoutMs']).toBe(1234); + }); + }); + + describe('checkRequiredAccess', () => { + const path = '/spot/accounts'; + + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(true); + }); + + it('returns false if fetch is not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(403); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(false); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + scope.done(); + }); + }); + + describe('getAccountBalance', () => { + const path = '/spot/accounts'; + + it('returns 0 if fetch not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(403); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe(0); + }); + + it('returns 0 if asset not found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200, generateGateAccountBalance(['OTHER'])); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe(0); + }); + + it('returns sum of available and locked if asset found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const balanceFixture = generateGateAccountBalance([asset]); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200, balanceFixture); + + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe( + parseFloat(balanceFixture[0].available) + + parseFloat(balanceFixture[0].locked), + ); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + scope.done(); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts new file mode 100644 index 0000000000..c9d95b0ba8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -0,0 +1,144 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { type SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; +import Environment from '@/utils/environment'; + +import { ExchangeApiClientError } from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; +import { fetchWithHandling } from './utils'; + +export const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; +export const DEVELOP_GATE_API_BASE_URL = + 'https://api-testnet.gateapi.io/api/v4'; + +export class GateExchangeClient implements ExchangeClient { + readonly id: SupportedExchange = 'gate'; + private readonly apiKey: string; + private readonly secretKey: string; + private readonly timeoutMs?: number; + private readonly apiBaseUrl = Environment.isDevelopment() + ? DEVELOP_GATE_API_BASE_URL + : GATE_API_BASE_URL; + private readonly logger = logger.child({ + context: GateExchangeClient.name, + exchange: this.id, + }); + + constructor( + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ) { + if (!creds?.apiKey || !creds?.secretKey) { + throw new ExchangeApiClientError('Incomplete credentials for Gate'); + } + this.apiKey = creds.apiKey; + this.secretKey = creds.secretKey; + this.timeoutMs = options?.timeoutMs; + } + + async checkRequiredAccess(): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = ''; + const body = ''; + const ts = String(Math.floor(Date.now() / 1000)); + const signature = this.signGateRequest( + method, + `/api/v4${path}`, + query, + body, + this.secretKey, + ts, + ); + + const res = await fetchWithHandling( + `${this.apiBaseUrl}${path}`, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); + + if (res.ok) return true; + return false; + } + + async getAccountBalance(asset: string): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = `currency=${encodeURIComponent(asset)}`; + const body = ''; + const ts = String(Math.floor(Date.now() / 1000)); + const requestPath = `/api/v4${path}`; + const signature = this.signGateRequest( + method, + requestPath, + query, + body, + this.secretKey, + ts, + ); + const url = `${this.apiBaseUrl}${path}?${query}`; + + const res = await fetchWithHandling( + url, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); + + if (!res.ok) { + return 0; + } + + const data = (await res.json()) as Array<{ + currency: string; + available: string; + locked?: string; + freeze?: string; + }>; + + const normalize = (item: { + currency: string; + available: string; + locked?: string; + freeze?: string; + }) => { + const free = parseFloat(item.available) || 0; + const locked = parseFloat(item.locked ?? item.freeze ?? '0') || 0; + return free + locked; + }; + + const entry = data.find((d) => d.currency === asset); + return entry ? normalize(entry) : 0; + } + + private signGateRequest( + method: string, + path: string, + query: string, + body: string, + secret: string, + ts: string, + ): string { + const bodyHash = createHash('sha512') + .update(body ?? '') + .digest('hex'); + const payload = [method, path, query, bodyHash, ts].join('\n'); + return createHmac('sha512', secret).update(payload).digest('hex'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts new file mode 100644 index 0000000000..083c7dd8bd --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts @@ -0,0 +1,2 @@ +export { ExchangeModule } from './exchange.module'; +export { ExchangeClientFactory } from './exchange-client.factory'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts new file mode 100644 index 0000000000..8b35c5a95a --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts @@ -0,0 +1,185 @@ +jest.mock('@/logger'); + +import crypto from 'crypto'; + +import { faker } from '@faker-js/faker'; +import nock from 'nock'; + +import { ExchangeApiClientError } from './errors'; +import { generateMexcAccountBalance } from './fixtures'; +import { MexcExchangeClient, MEXC_API_BASE_URL } from './mexc-exchange.client'; + +describe('MexcExchangeClient', () => { + afterAll(() => { + nock.restore(); + }); + + afterEach(() => { + jest.resetAllMocks(); + nock.cleanAll(); + }); + + describe('constructor', () => { + it('throws if credentials are missing', () => { + expect( + () => new MexcExchangeClient({ apiKey: '', secretKey: '' }), + ).toThrow(ExchangeApiClientError); + }); + + it('sets fields correctly', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const timeoutMs = faker.number.int(); + + const client = new MexcExchangeClient( + { apiKey, secretKey }, + { timeoutMs: timeoutMs }, + ); + + expect(client).toBeDefined(); + expect(client['apiKey']).toBe(apiKey); + expect(client['secretKey']).toBe(secretKey); + expect(client['timeoutMs']).toBe(timeoutMs); + }); + }); + + describe('signQuery', () => { + it('returns a valid signature', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const query = faker.string.sample(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + const signature = client['signQuery'](query); + + const expectedSignature = crypto + .createHmac('sha256', secretKey) + .update(query) + .digest('hex'); + expect(signature).toBe(expectedSignature); + }); + + it('getSignedQuery returns correct structure and signature', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const timestamp = faker.number.int(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(Date, 'now').mockReturnValue(timestamp); + const result = client['getSignedQuery'](); + expect(result).toHaveProperty('query'); + expect(result).toHaveProperty('signature'); + expect(result.query).toBe(`timestamp=${timestamp}&recvWindow=5000`); + + const expectedSignature = crypto + .createHmac('sha256', secretKey) + .update(result.query) + .digest('hex'); + expect(result.signature).toBe(expectedSignature); + jest.restoreAllMocks(); + }); + }); + + describe('checkRequiredAccess', () => { + const path = '/account'; + + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(200); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(true); + }); + + it('returns false if fetch is not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(403); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(false); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + + scope.done(); + }); + }); + + describe('getAccountBalance', () => { + const path = '/account'; + + it('returns 0 if fetch not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(403); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe(0); + }); + + it('returns 0 if asset not found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(200, generateMexcAccountBalance(['OTHER'])); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe(0); + }); + + it('returns sum of free and locked if asset found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const balanceFixture = generateMexcAccountBalance([asset]); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(200, balanceFixture); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe( + parseFloat(balanceFixture.balances[0].free) + + parseFloat(balanceFixture.balances[0].locked), + ); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + + scope.done(); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts new file mode 100644 index 0000000000..e92e4e9f01 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -0,0 +1,93 @@ +import { createHmac } from 'node:crypto'; + +import { type SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; + +import { ExchangeApiClientError } from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; +import { fetchWithHandling } from './utils'; + +export const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; + +export class MexcExchangeClient implements ExchangeClient { + readonly id: SupportedExchange = 'mexc'; + private readonly apiKey: string; + private readonly secretKey: string; + private readonly timeoutMs?: number; + private readonly logger = logger.child({ + context: MexcExchangeClient.name, + exchange: this.id, + }); + readonly recvWindow = 5000; + + constructor( + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ) { + if (!creds?.apiKey || !creds?.secretKey) { + throw new ExchangeApiClientError('Incomplete credentials for MEXC'); + } + this.apiKey = creds.apiKey; + this.secretKey = creds.secretKey; + this.timeoutMs = options?.timeoutMs; + } + + private signQuery(query: string): string { + return createHmac('sha256', this.secretKey).update(query).digest('hex'); + } + + async checkRequiredAccess(): Promise { + const path = '/account'; + const { query, signature } = this.getSignedQuery(); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const res = await fetchWithHandling( + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (res.ok) return true; + return false; + } + + async getAccountBalance(asset: string): Promise { + const path = '/account'; + const { query, signature } = this.getSignedQuery(); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const res = await fetchWithHandling( + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (!res.ok) { + return 0; + } + const data = (await res.json()) as { + balances?: Array<{ asset: string; free: string; locked: string }>; + }; + const balances = data.balances || []; + const entry = balances.find((b) => b.asset === asset); + if (!entry) return 0; + const total = + (parseFloat(entry.free || '0') || 0) + + (parseFloat(entry.locked || '0') || 0); + return total; + } + + private getSignedQuery(): { + query: string; + signature: string; + } { + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = this.signQuery(query); + return { query, signature }; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts new file mode 100644 index 0000000000..1fd5cfa68b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts @@ -0,0 +1,16 @@ +import type { SupportedExchange } from '@/common/constants'; + +export interface ExchangeClientCredentials { + apiKey: string; + secretKey: string; +} + +export interface ExchangeClientOptions { + timeoutMs?: number; +} + +export interface ExchangeClient { + readonly id: SupportedExchange; + checkRequiredAccess(): Promise; + getAccountBalance(asset: string): Promise; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts new file mode 100644 index 0000000000..8a2fd42fdc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts @@ -0,0 +1,27 @@ +import { DEFAULT_TIMEOUT_MS } from '@/common/constants'; +import Logger from '@/logger'; + +import { ExchangeApiClientError } from './errors'; + +export async function fetchWithHandling( + url: string, + headers: HeadersInit, + logger: typeof Logger, + timeoutMs?: number, +): Promise { + try { + const res = await fetch(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS), + }); + return res; + } catch (error) { + const message: string = `Failed to make request for exchange`; + logger.error(message, { + url, + error, + }); + throw new ExchangeApiClientError(message); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index e1bc6b34ae..1ca505f54b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -1,9 +1,10 @@ import { HMToken__factory } from '@human-protocol/core/typechain-types'; -import { ChainId } from '@human-protocol/sdk'; +import { ChainId, StakingClient } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { Wallet, ethers } from 'ethers'; import { Web3ConfigService, Web3Network } from '@/config'; +import logger from '@/logger'; import type { Chain, WalletWithProvider } from './types'; @@ -23,6 +24,9 @@ export class Web3Service { private signersByChainId: { [chainId: number]: WalletWithProvider; } = {}; + private readonly logger = logger.child({ + context: Web3Service.name, + }); constructor(private readonly web3ConfigService: Web3ConfigService) { const privateKey = this.web3ConfigService.privateKey; @@ -106,4 +110,22 @@ export class Web3Service { throw new Error('Failed to fetch token decimals'); } } + + async getStakedBalance(address: string): Promise { + try { + const chainId = this.web3ConfigService.reputationNetworkChainId; + const provider = this.getSigner(chainId).provider; + + const stakingClient = await StakingClient.build(provider); + const stakerInfo = await stakingClient.getStakerInfo(address); + + const total = + (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); + return Number(ethers.formatEther(total)); + } catch (error) { + const message = 'Failed to fetch staked balance'; + this.logger.error(message, { address, error }); + throw new Error(message); + } + } }