From 01bd3e2188257c6c5e7892afad28e487d6793f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 24 Oct 2025 16:36:19 +0200 Subject: [PATCH 01/10] Integrate staking amount requirement - Added ExchangeApiKeysModule for managing exchange API keys. - Added validation for exchange names using ExchangeNameValidator. - Integrated AES encryption for storing API keys securely. - Implemented exchange-specific services (GateExchangeService, MexcExchangeService) for interacting with respective APIs. - Enhanced AuthService to check staking eligibility based on exchange balances. - Added configuration services for encryption and staking parameters. - Created migration for the exchange_api_keys table in the database. --- docker-setup/docker-compose.dev.yml | 4 +- docker-setup/docker-compose.yml | 2 +- .../server/src/common/constants/index.ts | 3 + .../server/src/common/validators/exchange.ts | 32 ++++ .../server/src/common/validators/index.ts | 1 + .../server/src/config/config.module.ts | 6 + .../src/config/encryption-config.service.ts | 14 ++ .../server/src/config/env-schema.ts | 13 ++ .../server/src/config/index.ts | 2 + .../src/config/staking-config.service.ts | 31 ++++ .../server/src/database/database.module.ts | 2 + .../1761295918414-exchangeApiKeys.ts | 27 +++ .../server/src/modules/auth/auth.module.ts | 4 + .../server/src/modules/auth/auth.service.ts | 41 ++++ .../encryption/aes-encryption.service.ts | 89 +++++++++ .../modules/encryption/encryption.module.ts | 5 +- .../src/modules/encryption/fixtures/index.ts | 4 + .../exchange-api-key.entity.ts | 43 +++++ .../exchange-api-keys.controller.ts | 116 ++++++++++++ .../exchange-api-keys.dto.ts | 36 ++++ .../exchange-api-keys.error-filter.ts | 57 ++++++ .../exchange-api-keys.errors.ts | 28 +++ .../exchange-api-keys.module.ts | 17 ++ .../exchange-api-keys.repository.ts | 89 +++++++++ .../exchange-api-keys.service.ts | 111 +++++++++++ .../src/modules/exchange-api-keys/index.ts | 8 + .../src/modules/exchange/exchange.module.ts | 14 ++ .../exchange/exchange.router.service.ts | 54 ++++++ .../src/modules/exchange/exchange.service.ts | 9 + .../modules/exchange/gate-exchange.service.ts | 175 ++++++++++++++++++ .../server/src/modules/exchange/index.ts | 4 + .../modules/exchange/mexc-exchange.service.ts | 109 +++++++++++ .../server/src/modules/web3/web3.service.ts | 14 +- 33 files changed, 1158 insertions(+), 6 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/common/validators/exchange.ts create mode 100644 packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/config/staking-config.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts diff --git a/docker-setup/docker-compose.dev.yml b/docker-setup/docker-compose.dev.yml index b329016fb9..456c82f74e 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 @@ -146,7 +146,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql environment: POSTGRES_USER: *postgres_user POSTGRES_PASSWORD: *postgres_password diff --git a/docker-setup/docker-compose.yml b/docker-setup/docker-compose.yml index 5ecff8c246..3e6cae8d23 100644 --- a/docker-setup/docker-compose.yml +++ b/docker-setup/docker-compose.yml @@ -103,7 +103,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql environment: <<: *postgres_auth_vars healthcheck: 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..068b4ee38b 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,6 @@ 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']; +export type SupportedExchange = (typeof SUPPORTED_EXCHANGE_NAMES)[number]; 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..94b5d6a414 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts @@ -0,0 +1,31 @@ +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; + } +} 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/1761295918414-exchangeApiKeys.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts new file mode 100644 index 0000000000..6a5d2daed4 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExchangeApiKeys1761295918414 implements MigrationInterface { + name = 'ExchangeApiKeys1761295918414'; + + 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_8dcd9d18790ca62ebf1fb40cd3" ON "hmt"."exchange_api_keys" ("user_id", "exchange_name") `, + ); + 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_8dcd9d18790ca62ebf1fb40cd3"`, + ); + 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.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index b116b089ae..6b76c169d3 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 @@ -7,10 +7,13 @@ import { AuthConfigService, NDAConfigService, ServerConfigService, + StakingConfigService, Web3ConfigService, } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; +import { ExchangeRouterService } from '@/modules/exchange'; +import { ExchangeApiKeysRepository } from '@/modules/exchange-api-keys'; import { OperatorStatus, SiteKeyRepository, @@ -21,6 +24,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 +59,11 @@ export class AuthService { private readonly tokenRepository: TokenRepository, private readonly userRepository: UserRepository, private readonly userService: UserService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + private readonly exchangeRouterService: ExchangeRouterService, + private readonly stakingConfigService: StakingConfigService, private readonly web3ConfigService: Web3ConfigService, + private readonly web3Service: Web3Service, ) {} async signup(email: string, password: string): Promise { @@ -244,6 +252,38 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } + let stakeEligible = false; + const exchanges = + await this.exchangeApiKeysRepository.listExchangesByUserId(userEntity.id); + + let totalStake = 0; + + if (exchanges.length) { + const exchangeBalance = + await this.exchangeRouterService.getAccountBalance( + exchanges[0], + userEntity.id, + this.stakingConfigService.asset, + ); + + if (exchangeBalance >= this.stakingConfigService.minThreshold) { + totalStake = exchangeBalance; + } else if (userEntity.evmAddress) { + const onChainStake = await this.web3Service.getStakedBalance( + userEntity.evmAddress, + ); + totalStake = exchangeBalance + onChainStake; + } else { + totalStake = exchangeBalance; + } + } else if (userEntity.evmAddress) { + totalStake = await this.web3Service.getStakedBalance( + userEntity.evmAddress, + ); + } + + stakeEligible = totalStake >= this.stakingConfigService.minThreshold; + const jwtPayload = { email: userEntity.email, status: userEntity.status, @@ -254,6 +294,7 @@ export class AuthService { nda_signed: userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, reputation_network: this.web3ConfigService.operatorAddress, + stake_eligible: stakeEligible, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( (userQualification) => userQualification.qualification?.reference, 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..e0e9d578f9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts @@ -0,0 +1,89 @@ +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; + +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..0a0ba41458 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -0,0 +1,4 @@ +export const mockEncryptionConfigService = { + // 32-byte key for AES-256-GCM tests + aesEncryptionKey: '12345678901234567890123456789012', +}; 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..55f741c5af --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts @@ -0,0 +1,43 @@ +import { + BeforeInsert, + BeforeUpdate, + Column, + Entity, + Index, + ManyToOne, +} from 'typeorm'; + +import { DATABASE_SCHEMA_NAME } from '@/common/constants'; +import { BaseEntity } from '@/database'; + +import { UserEntity } from '../user'; + +@Entity({ schema: DATABASE_SCHEMA_NAME, name: 'exchange_api_keys' }) +@Index(['userId', 'exchangeName'], { 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; + + @BeforeInsert() + protected beforeInsert(): void { + this.createdAt = new Date(); + this.updatedAt = this.createdAt; + } + + @BeforeUpdate() + protected beforeUpdate(): void { + this.updatedAt = new Date(); + } +} 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..7804fdf2dd --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,116 @@ +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 { + EncrollExchangeApiKeysParamsDto, + EnrollExchangeApiKeysDto, + EnrollExchangeApiKeysResponseDto, + ExchangeNameParamDto, +} from './exchange-api-keys.dto'; +import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; +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: '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: EncrollExchangeApiKeysParamsDto, + @Body() data: EnrollExchangeApiKeysDto, + ): Promise { + const userId = request.user.id; + const exchangeName = params.exchangeName; + + const key = await this.exchangeApiKeysService.enroll({ + userId, + exchangeName, + apiKey: data.apiKey, + secretKey: data.secretKey, + }); + + return { id: key.id }; + } + + @ApiOperation({ + summary: 'Delete API keys for exchange', + }) + @ApiResponse({ + status: 204, + description: 'Exchange API keys deleted', + }) + @HttpCode(204) + @Delete('/:exchange_name') + async delete( + @Req() request: RequestWithUser, + @Param() params: EncrollExchangeApiKeysParamsDto, + ): Promise { + const userId = request.user.id; + const exchangeName = params.exchangeName; + + await this.exchangeApiKeysRepository.deleteByUserAndExchange( + userId, + exchangeName, + ); + } + + @ApiOperation({ + summary: 'Retreive API keys for exchange', + description: + 'This functionality is purely for dev solely and works only in non-production environments', + }) + @Get('/:exchange_name') + async retrieve( + @Req() request: RequestWithUser, + @Param() params: ExchangeNameParamDto, + ): Promise { + if (!Environment.isDevelopment()) { + throw new ForbiddenException(); + } + + const userId = request.user.id; + const exchangeName = params.exchangeName; + + return this.exchangeApiKeysService.retrieve(userId, exchangeName); + } +} 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..e60691a011 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +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, + }) + @Expose({ name: 'exchange_name' }) + @Validate(ExchangeNameValidator) + exchangeName: string; +} +export class EncrollExchangeApiKeysParamsDto extends ExchangeNameParamDto {} + +export class EnrollExchangeApiKeysResponseDto { + @ApiProperty() + id: number; +} 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..7fcf506afe --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -0,0 +1,57 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import logger from '@/logger'; + +import { + ExchangeApiKeyNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, +} from './exchange-api-keys.errors'; +import { UserNotFoundError } from '../user'; + +@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..754e6e19f3 --- /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, + readonly exchangeName: string, + ) { + 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) { + 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..06a318b2a4 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -0,0 +1,17 @@ +import { Module, forwardRef } 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: [forwardRef(() => 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..403b984840 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; + +type FindOptions = { + relations?: FindManyOptions['relations']; +}; + +@Injectable() +export class ExchangeApiKeysRepository extends Repository { + constructor(dataSource: DataSource) { + super(ExchangeApiKeyEntity, dataSource.createEntityManager()); + } + + async findOneByUserId( + userId: number, + options: FindOptions = {}, + ): Promise { + if (!userId) { + throw new Error('Invalid arguments'); + } + return this.findOne({ + where: { userId }, + relations: options.relations, + }); + } + + async listExchangesByUserId(userId: number): Promise { + if (!userId) { + throw new Error('userId is required'); + } + + const results: Array> = + await this.find({ + where: { + userId, + }, + select: { + exchangeName: true, + }, + }); + + return results.map((r) => r.exchangeName); + } + + async deleteByUserAndExchange( + userId: number, + exchangeName: string, + ): Promise { + if (!userId) { + throw new Error('userId is required'); + } + if (!exchangeName) { + throw new Error('exchangeName is required'); + } + + await this.delete({ userId, exchangeName }); + } + + async findOneByUserAndExchange( + userId: number, + exchangeName: string, + ): Promise { + if (!userId) { + throw new Error('userId is required'); + } + if (!exchangeName) { + throw new Error('exchangeName is required'); + } + + return this.findOneBy({ + userId, + exchangeName, + }); + } + + async findByUserId(userId: number): Promise { + if (!userId) { + throw new Error('userId is required'); + } + + return this.find({ + where: { + 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..fc64955525 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,111 @@ +import { Inject, Injectable, forwardRef } from '@nestjs/common'; + +import { isValidExchangeName } from '@/common/validators/exchange'; +import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; +import { ExchangeRouterService } from '@/modules/exchange/exchange.router.service'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { + ExchangeApiKeyNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, +} from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { UserNotFoundError, UserRepository } from '../user'; + +@Injectable() +export class ExchangeApiKeysService { + constructor( + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + private readonly userRepository: UserRepository, + private readonly aesEncryptionService: AesEncryptionService, + @Inject(forwardRef(() => ExchangeRouterService)) + private readonly exchangeRouterService: ExchangeRouterService, + ) {} + + 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); + } + + const creds = { apiKey, secret: secretKey }; + if ( + !this.exchangeRouterService.checkRequiredCredentials(exchangeName, creds) + ) { + throw new IncompleteKeySuppliedError(exchangeName); + } + + const hasRequiredAccess = + await this.exchangeRouterService.checkRequiredAccess(exchangeName, creds); + 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; + enrolledKey.updatedAt = new Date(); + + await this.exchangeApiKeysRepository.upsert(enrolledKey, [ + 'userId', + 'exchangeName', + ]); + + return enrolledKey; + } + + async retrieve( + userId: number, + exchangeName: string, + ): Promise<{ id: number; apiKey: string; secretKey: string }> { + const entity = + await this.exchangeApiKeysRepository.findOneByUserAndExchange( + userId, + exchangeName, + ); + if (!entity) { + throw new ExchangeApiKeyNotFoundError(userId, exchangeName); + } + + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + this.aesEncryptionService.decrypt(entity.apiKey), + this.aesEncryptionService.decrypt(entity.secretKey), + ]); + + return { + id: entity.id, + apiKey: decryptedApiKey.toString(), + secretKey: decryptedSecretKey.toString(), + }; + } +} 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/exchange.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts new file mode 100644 index 0000000000..7fca563319 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts @@ -0,0 +1,14 @@ +import { Module, forwardRef } from '@nestjs/common'; + +import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; + +import { ExchangeRouterService } from './exchange.router.service'; +import { GateExchangeService } from './gate-exchange.service'; +import { MexcExchangeService } from './mexc-exchange.service'; + +@Module({ + imports: [forwardRef(() => ExchangeApiKeysModule)], + providers: [MexcExchangeService, GateExchangeService, ExchangeRouterService], + exports: [ExchangeRouterService], +}) +export class ExchangeModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts new file mode 100644 index 0000000000..3eb80aa4f8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; + +import { ExchangeCredentials, ExchangeService } from './exchange.service'; +import { GateExchangeService } from './gate-exchange.service'; +import { MexcExchangeService } from './mexc-exchange.service'; + +@Injectable() +export class ExchangeRouterService { + private servicesMap: Record; + + constructor( + private readonly mexcService: MexcExchangeService, + private readonly gateService: GateExchangeService, + ) { + this.servicesMap = { + mexc: this.mexcService, + gate: this.gateService, + }; + } + + checkRequiredCredentials( + exchangeName: string, + creds: ExchangeCredentials, + ): boolean { + const service = this.servicesMap[exchangeName.toLowerCase()]; + if (!service) { + throw new Error(`Exchange service not found for ${exchangeName}`); + } + return service.checkRequiredCredentials(creds); + } + + async checkRequiredAccess( + exchangeName: string, + creds: ExchangeCredentials, + ): Promise { + const service = this.servicesMap[exchangeName.toLowerCase()]; + if (!service) { + throw new Error(`Exchange service not found for ${exchangeName}`); + } + return service.checkRequiredAccess(creds); + } + + async getAccountBalance( + exchangeName: string, + userId: number, + asset?: string, + ): Promise { + const service = this.servicesMap[exchangeName.toLowerCase()]; + if (!service) { + throw new Error(`Exchange service not found for ${exchangeName}`); + } + return service.getAccountBalance(userId, asset); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts new file mode 100644 index 0000000000..41bfb7721e --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts @@ -0,0 +1,9 @@ +export type ExchangeCredentials = { apiKey: string; secret: string }; + +export abstract class ExchangeService { + abstract checkRequiredCredentials(creds: ExchangeCredentials): boolean; + + abstract checkRequiredAccess(creds: ExchangeCredentials): Promise; + + abstract getAccountBalance(userId: number, asset?: string): Promise; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts new file mode 100644 index 0000000000..ea1b6abd8b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts @@ -0,0 +1,175 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { Inject, Injectable, forwardRef } from '@nestjs/common'; + +import { StakingConfigService } from '@/config'; +import logger from '@/logger'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; +import Environment from '@/utils/environment'; + +import { ExchangeCredentials, ExchangeService } from './exchange.service'; + +const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; +const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; + +function 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'); +} + +@Injectable() +export class GateExchangeService extends ExchangeService { + readonly id = 'gate' as const; + private readonly logger = logger.child({ context: GateExchangeService.name }); + private readonly apiBaseUrl = + process.env.GATE_API_BASE_URL && process.env.GATE_API_BASE_URL.length > 0 + ? process.env.GATE_API_BASE_URL + : Environment.isDevelopment() + ? DEVELOP_GATE_API_BASE_URL + : GATE_API_BASE_URL; + + constructor( + @Inject(forwardRef(() => ExchangeApiKeysService)) + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly stakingConfigService: StakingConfigService, + ) { + super(); + } + + checkRequiredCredentials(creds: ExchangeCredentials): boolean { + return Boolean(creds?.apiKey && creds?.secret); + } + + async checkRequiredAccess(creds: ExchangeCredentials): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = ''; + const body = ''; + const ts = String(Math.floor(Date.now() / 1000)); + const signature = signGateRequest( + method, + `/api/v4${path}`, + query, + body, + creds.secret, + ts, + ); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(`${this.apiBaseUrl}${path}`, { + method, + headers: { + KEY: creds.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text(); + console.warn('Gate access check http error', { + status: res.status, + body: text, + }); + return false; + } + return true; + } catch (error) { + this.logger.error('Gate access check failed', { + error, + }); + return false; + } finally { + clearTimeout(timeout); + } + } + + async getAccountBalance(userId: number, asset = 'HMT'): Promise { + const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); + + 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 = signGateRequest( + method, + requestPath, + query, + body, + creds.secretKey, + ts, + ); + const url = `${this.apiBaseUrl}${path}?${query}`; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(url, { + method, + headers: { + KEY: creds.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text(); + this.logger.error('Gate getAccountBalance http error', { + status: res.status, + body: text, + }); + 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; + } catch (error) { + this.logger.error('Gate getAccountBalance failed', { + error, + }); + return 0; + } finally { + clearTimeout(timeout); + } + } +} 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..49bc0304e4 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts @@ -0,0 +1,4 @@ +export { ExchangeModule } from './exchange.module'; +export { ExchangeRouterService } from './exchange.router.service'; +export { MexcExchangeService } from './mexc-exchange.service'; +export { GateExchangeService } from './gate-exchange.service'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts new file mode 100644 index 0000000000..65b976f971 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts @@ -0,0 +1,109 @@ +import { createHmac } from 'node:crypto'; + +import { Inject, Injectable, forwardRef } from '@nestjs/common'; + +import { StakingConfigService } from '@/config'; +import logger from '@/logger'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; + +import { ExchangeCredentials, ExchangeService } from './exchange.service'; + +const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; + +@Injectable() +export class MexcExchangeService extends ExchangeService { + readonly id = 'mexc' as const; + readonly recvWindow = 5000; + private readonly logger = logger.child({ context: MexcExchangeService.name }); + + constructor( + @Inject(forwardRef(() => ExchangeApiKeysService)) + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly stakingConfigService: StakingConfigService, + ) { + super(); + } + + checkRequiredCredentials(creds: ExchangeCredentials): boolean { + return Boolean(creds?.apiKey && creds?.secret); + } + + async checkRequiredAccess(creds: ExchangeCredentials): Promise { + const path = '/account'; + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = createHmac('sha256', creds.secret) + .update(query) + .digest('hex'); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(url, { + method: 'GET', + headers: { 'X-MEXC-APIKEY': creds.apiKey }, + signal: controller.signal, + } as RequestInit); + return res.ok; + } catch (error) { + this.logger.error('MEXC access check failed', { + error, + }); + return false; + } finally { + clearTimeout(timeout); + } + } + + async getAccountBalance(userId: number, asset = 'HMT'): Promise { + const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); + + const path = '/account'; + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = createHmac('sha256', creds.secretKey) + .update(query) + .digest('hex'); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(url, { + method: 'GET', + headers: { + 'X-MEXC-APIKEY': creds.apiKey, + }, + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Http error ${res.status}: ${text}`); + } + 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; + } catch (error) { + this.logger.error('MEXC getAccountBalance failed', { + error, + }); + return 0; + } finally { + clearTimeout(timeout); + } + } +} 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..58c5801ef8 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,5 +1,5 @@ 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'; @@ -106,4 +106,16 @@ export class Web3Service { throw new Error('Failed to fetch token decimals'); } } + + async getStakedBalance(address: string): Promise { + 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)); + } } From 23be3beaaedcab493959dfdd7bee758abf7e4a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 24 Oct 2025 18:52:21 +0200 Subject: [PATCH 02/10] Add stake eligibility checks in Human App --- .../src/common/guards/strategy/jwt.http.ts | 2 + .../src/common/utils/jwt-token.model.ts | 2 + .../job-assignment.controller.ts | 23 +++++++ .../jobs-discovery.controller.ts | 65 +++++++++++-------- .../oracle-discovery.controller.ts | 4 ++ 5 files changed, 70 insertions(+), 26 deletions(-) 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..c72ada8f12 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; + 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, + stake_eligible: payload.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..aea8e277c3 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() + stake_eligible?: boolean; + @AutoMap() email?: string; @AutoMap() qualifications?: string[]; 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..6f0090c85b 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?.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?.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?.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?.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/jobs-discovery/jobs-discovery.controller.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts index abfc928d02..2efa2dd23f 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?.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/oracle-discovery/oracle-discovery.controller.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts index d0584d2635..eff081faf3 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 @@ -47,6 +47,10 @@ export class OracleDiscoveryController { HttpStatus.FORBIDDEN, ); } + if (!req.user?.stake_eligible) { + return []; + } + const command = this.mapper.map(query, GetOraclesQuery, GetOraclesCommand); const oracles = await this.oracleDiscoveryService.getOracles(command); From bc964902c384413bb8d0c925544c85772dcacef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 15:35:17 +0100 Subject: [PATCH 03/10] - Added new ExchangeApiKeys module in Human App. - Introduced ExchangeClientFactory to handle exchange client creation for different exchanges. - Implemented GateExchangeClient and MexcExchangeClient for API interactions. - Removed deprecated ExchangeRouterService and related services. - Updated error handling to include ExchangeApiClientError for better clarity. --- docker-setup/docker-compose.yml | 2 +- .../apps/human-app/server/src/app.module.ts | 4 + .../common/config/gateway-config.service.ts | 15 ++ .../server/src/common/enums/http-method.ts | 1 + .../enums/reputation-oracle-endpoints.ts | 3 + .../src/common/guards/strategy/jwt.http.ts | 4 +- .../src/common/utils/jwt-token.model.ts | 2 +- .../reputation-oracle.gateway.ts | 43 +++++ .../reputation-oracle.mapper.profile.ts | 13 ++ .../exchange-api-keys.controller.ts | 80 ++++++++ .../exchange-api-keys.mapper.profile.ts | 20 ++ .../exchange-api-keys.module.ts | 13 ++ .../exchange-api-keys.service.ts | 32 ++++ .../model/exchange-api-keys.model.ts | 45 +++++ .../job-assignment.controller.ts | 8 +- .../jobs-discovery.controller.ts | 2 +- .../oracle-discovery.controller.ts | 12 +- .../server/scripts/setup-kv-store.ts | 5 +- .../server/scripts/setup-staking.ts | 7 +- .../server/src/common/constants/index.ts | 4 +- .../interceptors/transform.interceptor.ts | 4 + ...ys.ts => 1761653939799-exchangeApiKeys.ts} | 8 +- .../server/src/modules/auth/auth.service.ts | 86 +++++---- .../encryption/aes-encryption.service.ts | 11 ++ .../src/modules/encryption/fixtures/index.ts | 4 +- .../exchange-api-key.entity.ts | 25 +-- .../exchange-api-keys.controller.ts | 63 ++++--- .../exchange-api-keys.dto.ts | 11 +- .../exchange-api-keys.error-filter.ts | 5 +- .../exchange-api-keys.errors.ts | 10 +- .../exchange-api-keys.module.ts | 4 +- .../exchange-api-keys.repository.ts | 75 +------- .../exchange-api-keys.service.ts | 72 +++---- .../server/src/modules/exchange/errors.ts | 3 + .../exchange/exchange-client.factory.ts | 31 ++++ .../src/modules/exchange/exchange.module.ts | 13 +- .../exchange/exchange.router.service.ts | 54 ------ .../src/modules/exchange/exchange.service.ts | 9 - .../modules/exchange/gate-exchange.client.ts | 172 +++++++++++++++++ .../modules/exchange/gate-exchange.service.ts | 175 ------------------ .../server/src/modules/exchange/index.ts | 4 +- .../modules/exchange/mexc-exchange.client.ts | 121 ++++++++++++ .../modules/exchange/mexc-exchange.service.ts | 109 ----------- .../server/src/modules/exchange/types.ts | 16 ++ 44 files changed, 816 insertions(+), 584 deletions(-) create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts rename packages/apps/reputation-oracle/server/src/database/migrations/{1761295918414-exchangeApiKeys.ts => 1761653939799-exchangeApiKeys.ts} (79%) create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/types.ts diff --git a/docker-setup/docker-compose.yml b/docker-setup/docker-compose.yml index 3e6cae8d23..5ecff8c246 100644 --- a/docker-setup/docker-compose.yml +++ b/docker-setup/docker-compose.yml @@ -103,7 +103,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql + - postgres-data:/var/lib/postgresql/data environment: <<: *postgres_auth_vars healthcheck: 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 c72ada8f12..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,7 +44,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { status: string; wallet_address: string; reputation_network: string; - stake_eligible?: boolean; + is_stake_eligible?: boolean; qualifications?: string[]; site_key?: string; email?: string; @@ -59,7 +59,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { wallet_address: payload.wallet_address, status: payload.status, reputation_network: payload.reputation_network, - stake_eligible: payload.stake_eligible, + 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 aea8e277c3..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,7 +10,7 @@ export class JwtUserData { @AutoMap() reputation_network: string; @AutoMap() - stake_eligible?: boolean; + is_stake_eligible?: boolean; @AutoMap() email?: string; @AutoMap() 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..a827806b04 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,80 @@ +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 { + DeleteExchangeApiKeysCommand, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, + RetrieveExchangeApiKeysCommand, + 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 { + const command = new DeleteExchangeApiKeysCommand(); + command.token = req.token; + await this.service.delete(command); + } + + @ApiOperation({ + summary: 'Retrieve API keys for exchange', + }) + @Get('/') + async retrieve( + @Request() req: RequestWithUser, + ): Promise { + const command = new RetrieveExchangeApiKeysCommand(); + command.token = req.token; + return this.service.retrieve(command); + } +} 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..2f8d117366 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { + DeleteExchangeApiKeysCommand, + EnrollExchangeApiKeysCommand, + RetrieveExchangeApiKeysCommand, + RetrieveExchangeApiKeysResponse, +} from './model/exchange-api-keys.model'; + +@Injectable() +export class ExchangeApiKeysService { + constructor( + private readonly reputationOracle: ReputationOracleGateway, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + enroll(command: EnrollExchangeApiKeysCommand): Promise<{ id: number }> { + return this.reputationOracle.enrollExchangeApiKeys(command); + } + + delete(command: DeleteExchangeApiKeysCommand): Promise { + return this.reputationOracle.deleteExchangeApiKeys(command.token); + } + + retrieve( + command: RetrieveExchangeApiKeysCommand, + ): Promise { + return this.reputationOracle.retrieveExchangeApiKeys(command.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..04179d3eeb --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts @@ -0,0 +1,45 @@ +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 DeleteExchangeApiKeysCommand { + token: string; + exchangeName: string; +} + +export class RetrieveExchangeApiKeysCommand { + token: string; + exchangeName: string; +} + +export class RetrieveExchangeApiKeysResponse { + apiKey: string; +} 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 6f0090c85b..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 @@ -48,7 +48,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ): Promise { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { throw new ForbiddenException('Stake requirement not met'); } // TODO: temporal - THIRSTYFI @@ -109,7 +109,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ): Promise { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { return { page: 0, page_size: 1, @@ -182,7 +182,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ) { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { throw new ForbiddenException('Stake requirement not met'); } const command = this.mapper.map(dto, ResignJobDto, ResignJobCommand); @@ -200,7 +200,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ) { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { throw new ForbiddenException('Stake requirement not met'); } const command = new JobsFetchParamsCommand(); 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 2efa2dd23f..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 @@ -53,7 +53,7 @@ export class JobsDiscoveryController { } // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { return { page: 0, page_size: 1, 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 eff081faf3..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( @@ -47,10 +53,6 @@ export class OracleDiscoveryController { HttpStatus.FORBIDDEN, ); } - if (!req.user?.stake_eligible) { - return []; - } - const command = this.mapper.map(query, GetOraclesQuery, GetOraclesCommand); const oracles = await this.oracleDiscoveryService.getOracles(command); diff --git a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts index e64b8aaf7f..5165db585c 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts @@ -1,6 +1,6 @@ import { KVStoreClient, KVStoreKeys, Role } from '@human-protocol/sdk'; import * as dotenv from 'dotenv'; -import { Wallet, ethers } from 'ethers'; +import { Wallet, ethers, NonceManager } from 'ethers'; import * as Minio from 'minio'; const isLocalEnv = process.env.LOCAL === 'true'; @@ -105,7 +105,8 @@ async function setup(): Promise { } const provider = new ethers.JsonRpcProvider(RPC_URL); - const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const wallet = new NonceManager(baseWallet); const kvStoreClient = await KVStoreClient.build(wallet); diff --git a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts index 8d266da004..71f6c4c57d 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts @@ -1,12 +1,12 @@ +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'; +import { ethers, NonceManager, Wallet } from 'ethers'; dotenv.config({ path: '.env.local' }); @@ -26,7 +26,8 @@ export async function setup(): Promise { const { hmtAddress: hmtTokenAddress, stakingAddress } = NETWORKS[ ChainId.LOCALHOST ] as NetworkData; - const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const wallet = new NonceManager(baseWallet); const hmtContract = HMToken__factory.connect(hmtTokenAddress, wallet); const hmtTx = await hmtContract.approve(stakingAddress, 1); 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 068b4ee38b..2a0061d1c3 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -16,5 +16,7 @@ export const LOGOUT_PATH = '/auth/logout'; export const BACKOFF_INTERVAL_SECONDS = 120; -export const SUPPORTED_EXCHANGE_NAMES = ['mexc', 'gate']; +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/database/migrations/1761295918414-exchangeApiKeys.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts similarity index 79% rename from packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts rename to packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts index 6a5d2daed4..3b5f7f91bd 100644 --- a/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts @@ -1,14 +1,14 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class ExchangeApiKeys1761295918414 implements MigrationInterface { - name = 'ExchangeApiKeys1761295918414'; +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_8dcd9d18790ca62ebf1fb40cd3" ON "hmt"."exchange_api_keys" ("user_id", "exchange_name") `, + `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`, @@ -20,7 +20,7 @@ export class ExchangeApiKeys1761295918414 implements MigrationInterface { `ALTER TABLE "hmt"."exchange_api_keys" DROP CONSTRAINT "FK_96ee74195b058a1b55afc49f673"`, ); await queryRunner.query( - `DROP INDEX "hmt"."IDX_8dcd9d18790ca62ebf1fb40cd3"`, + `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.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index 6b76c169d3..8c583e5eb7 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,6 +2,7 @@ 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, @@ -12,8 +13,8 @@ import { } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; -import { ExchangeRouterService } from '@/modules/exchange'; -import { ExchangeApiKeysRepository } from '@/modules/exchange-api-keys'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { OperatorStatus, SiteKeyRepository, @@ -59,8 +60,8 @@ export class AuthService { private readonly tokenRepository: TokenRepository, private readonly userRepository: UserRepository, private readonly userService: UserService, - private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, - private readonly exchangeRouterService: ExchangeRouterService, + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeClientFactory: ExchangeClientFactory, private readonly stakingConfigService: StakingConfigService, private readonly web3ConfigService: Web3ConfigService, private readonly web3Service: Web3Service, @@ -252,37 +253,7 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } - let stakeEligible = false; - const exchanges = - await this.exchangeApiKeysRepository.listExchangesByUserId(userEntity.id); - - let totalStake = 0; - - if (exchanges.length) { - const exchangeBalance = - await this.exchangeRouterService.getAccountBalance( - exchanges[0], - userEntity.id, - this.stakingConfigService.asset, - ); - - if (exchangeBalance >= this.stakingConfigService.minThreshold) { - totalStake = exchangeBalance; - } else if (userEntity.evmAddress) { - const onChainStake = await this.web3Service.getStakedBalance( - userEntity.evmAddress, - ); - totalStake = exchangeBalance + onChainStake; - } else { - totalStake = exchangeBalance; - } - } else if (userEntity.evmAddress) { - totalStake = await this.web3Service.getStakedBalance( - userEntity.evmAddress, - ); - } - - stakeEligible = totalStake >= this.stakingConfigService.minThreshold; + const stakeEligible = await this.calculateStakeEligible(userEntity); const jwtPayload = { email: userEntity.email, @@ -293,8 +264,8 @@ export class AuthService { kyc_status: userEntity.kyc?.status, nda_signed: userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, + is_stake_eligible: stakeEligible, reputation_network: this.web3ConfigService.operatorAddress, - stake_eligible: stakeEligible, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( (userQualification) => userQualification.qualification?.reference, @@ -306,6 +277,49 @@ export class AuthService { return this.generateTokens(userEntity.id, jwtPayload); } + private async calculateStakeEligible( + userEntity: Web2UserEntity | UserEntity, + ): Promise { + 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 as Error)?.message, + }); + } + } + + 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.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts index e0e9d578f9..d42ddbfa30 100644 --- 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 @@ -8,6 +8,17 @@ 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; 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 index 0a0ba41458..a2b44a974e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -1,4 +1,6 @@ +import { faker } from '@faker-js/faker'; + export const mockEncryptionConfigService = { // 32-byte key for AES-256-GCM tests - aesEncryptionKey: '12345678901234567890123456789012', + aesEncryptionKey: 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 index 55f741c5af..5a9252614e 100644 --- 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 @@ -1,19 +1,11 @@ -import { - BeforeInsert, - BeforeUpdate, - Column, - Entity, - Index, - ManyToOne, -} from 'typeorm'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '@/common/constants'; import { BaseEntity } from '@/database'; - -import { UserEntity } from '../user'; +import type { UserEntity } from '@/modules/user'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'exchange_api_keys' }) -@Index(['userId', 'exchangeName'], { unique: true }) +@Index(['userId'], { unique: true }) export class ExchangeApiKeyEntity extends BaseEntity { @Column('varchar', { length: 20 }) exchangeName: string; @@ -29,15 +21,4 @@ export class ExchangeApiKeyEntity extends BaseEntity { @Column() userId: number; - - @BeforeInsert() - protected beforeInsert(): void { - this.createdAt = new Date(); - this.updatedAt = this.createdAt; - } - - @BeforeUpdate() - protected beforeUpdate(): void { - this.updatedAt = new Date(); - } } 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 index 7804fdf2dd..ceb1de5de0 100644 --- 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 @@ -16,16 +16,17 @@ import { ApiOperation, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import type { RequestWithUser } from '@/common/types'; import Environment from '@/utils/environment'; import { - EncrollExchangeApiKeysParamsDto, + ExchangeNameParamDto, EnrollExchangeApiKeysDto, EnrollExchangeApiKeysResponseDto, - ExchangeNameParamDto, + EnrolledApiKeyDto, } from './exchange-api-keys.dto'; import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; @@ -41,6 +42,26 @@ export class ExchangeApiKeysController { 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, + schema: { + nullable: true, + allOf: [{ $ref: getSchemaPath(EnrolledApiKeyDto) }], + }, + }) + @Get('/') + async retrieveEnrolledApiKeys( + @Req() request: RequestWithUser, + ): Promise { + const userId = request.user.id; + + return this.exchangeApiKeysService.retrievedEnrolledApiKey(userId); + } + @ApiOperation({ summary: 'Enroll API keys for exchange', description: @@ -56,15 +77,12 @@ export class ExchangeApiKeysController { @Post('/:exchange_name') async enroll( @Req() request: RequestWithUser, - @Param() params: EncrollExchangeApiKeysParamsDto, + @Param() params: ExchangeNameParamDto, @Body() data: EnrollExchangeApiKeysDto, ): Promise { - const userId = request.user.id; - const exchangeName = params.exchangeName; - const key = await this.exchangeApiKeysService.enroll({ - userId, - exchangeName, + userId: request.user.id, + exchangeName: params.exchangeName, apiKey: data.apiKey, secretKey: data.secretKey, }); @@ -73,25 +91,16 @@ export class ExchangeApiKeysController { } @ApiOperation({ - summary: 'Delete API keys for exchange', + summary: 'Delete API keys', }) @ApiResponse({ status: 204, description: 'Exchange API keys deleted', }) @HttpCode(204) - @Delete('/:exchange_name') - async delete( - @Req() request: RequestWithUser, - @Param() params: EncrollExchangeApiKeysParamsDto, - ): Promise { - const userId = request.user.id; - const exchangeName = params.exchangeName; - - await this.exchangeApiKeysRepository.deleteByUserAndExchange( - userId, - exchangeName, - ); + @Delete('/') + async delete(@Req() request: RequestWithUser): Promise { + await this.exchangeApiKeysRepository.deleteByUser(request.user.id); } @ApiOperation({ @@ -99,18 +108,12 @@ export class ExchangeApiKeysController { description: 'This functionality is purely for dev solely and works only in non-production environments', }) - @Get('/:exchange_name') - async retrieve( - @Req() request: RequestWithUser, - @Param() params: ExchangeNameParamDto, - ): Promise { + @Get('/exchange') + async retrieve(@Req() request: RequestWithUser): Promise { if (!Environment.isDevelopment()) { throw new ForbiddenException(); } - const userId = request.user.id; - const exchangeName = params.exchangeName; - - return this.exchangeApiKeysService.retrieve(userId, exchangeName); + return this.exchangeApiKeysService.retrieve(request.user.id); } } 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 index e60691a011..220f04c953 100644 --- 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 @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; import { IsNotEmpty, IsString, MaxLength, Validate } from 'class-validator'; import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; @@ -24,13 +23,19 @@ export class ExchangeNameParamDto { name: 'exchange_name', enum: SUPPORTED_EXCHANGE_NAMES, }) - @Expose({ name: 'exchange_name' }) @Validate(ExchangeNameValidator) exchangeName: string; } -export class EncrollExchangeApiKeysParamsDto extends ExchangeNameParamDto {} 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 index 7fcf506afe..38aa4e7943 100644 --- 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 @@ -14,6 +14,7 @@ import { KeyAuthorizationError, ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; +import { ExchangeApiClientError } from '../exchange/errors'; import { UserNotFoundError } from '../user'; @Catch( @@ -42,8 +43,8 @@ export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { exception instanceof ActiveExchangeApiKeyExistsError ) { status = HttpStatus.UNPROCESSABLE_ENTITY; - // } else if (exception instanceof ExchangeApiClientError) { - // status = HttpStatus.SERVICE_UNAVAILABLE; + } else if (exception instanceof ExchangeApiClientError) { + status = HttpStatus.SERVICE_UNAVAILABLE; } else if (exception instanceof ExchangeApiKeyNotFoundError) { status = HttpStatus.NOT_FOUND; } 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 index 754e6e19f3..e45850a31c 100644 --- 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 @@ -1,10 +1,7 @@ import { BaseError } from '@/common/errors/base'; export class ExchangeApiKeyNotFoundError extends BaseError { - constructor( - readonly userId: number, - readonly exchangeName: string, - ) { + constructor(readonly userId: number) { super('Exchange API key not found'); } } @@ -22,7 +19,10 @@ export class KeyAuthorizationError extends BaseError { } export class ActiveExchangeApiKeyExistsError extends BaseError { - constructor(readonly userId: number) { + 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 index 06a318b2a4..8f975d6914 100644 --- 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 @@ -1,4 +1,4 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { EncryptionModule } from '@/modules/encryption'; import { ExchangeModule } from '@/modules/exchange/exchange.module'; @@ -9,7 +9,7 @@ import { ExchangeApiKeysService } from './exchange-api-keys.service'; import { UserModule } from '../user'; @Module({ - imports: [forwardRef(() => ExchangeModule), EncryptionModule, UserModule], + imports: [ExchangeModule, EncryptionModule, UserModule], providers: [ExchangeApiKeysRepository, ExchangeApiKeysService], controllers: [ExchangeApiKeysController], exports: [ExchangeApiKeysRepository, ExchangeApiKeysService], 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 index 403b984840..ea0d264915 100644 --- 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 @@ -1,89 +1,30 @@ import { Injectable } from '@nestjs/common'; -import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { DataSource } from 'typeorm'; -import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { BaseRepository } from '@/database'; -type FindOptions = { - relations?: FindManyOptions['relations']; -}; +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; @Injectable() -export class ExchangeApiKeysRepository extends Repository { +export class ExchangeApiKeysRepository extends BaseRepository { constructor(dataSource: DataSource) { - super(ExchangeApiKeyEntity, dataSource.createEntityManager()); + super(ExchangeApiKeyEntity, dataSource); } - async findOneByUserId( - userId: number, - options: FindOptions = {}, - ): Promise { + async findOneByUserId(userId: number): Promise { if (!userId) { throw new Error('Invalid arguments'); } return this.findOne({ where: { userId }, - relations: options.relations, }); } - async listExchangesByUserId(userId: number): Promise { + async deleteByUser(userId: number): Promise { if (!userId) { throw new Error('userId is required'); } - const results: Array> = - await this.find({ - where: { - userId, - }, - select: { - exchangeName: true, - }, - }); - - return results.map((r) => r.exchangeName); - } - - async deleteByUserAndExchange( - userId: number, - exchangeName: string, - ): Promise { - if (!userId) { - throw new Error('userId is required'); - } - if (!exchangeName) { - throw new Error('exchangeName is required'); - } - - await this.delete({ userId, exchangeName }); - } - - async findOneByUserAndExchange( - userId: number, - exchangeName: string, - ): Promise { - if (!userId) { - throw new Error('userId is required'); - } - if (!exchangeName) { - throw new Error('exchangeName is required'); - } - - return this.findOneBy({ - userId, - exchangeName, - }); - } - - async findByUserId(userId: number): Promise { - if (!userId) { - throw new Error('userId is required'); - } - - return this.find({ - where: { - userId, - }, - }); + await this.delete({ 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 index fc64955525..c9c3db7d6b 100644 --- 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 @@ -1,18 +1,17 @@ -import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { isValidExchangeName } from '@/common/validators/exchange'; import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; -import { ExchangeRouterService } from '@/modules/exchange/exchange.router.service'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; import { - ExchangeApiKeyNotFoundError, - IncompleteKeySuppliedError, KeyAuthorizationError, ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; import { UserNotFoundError, UserRepository } from '../user'; +import { EnrolledApiKeyDto } from './exchange-api-keys.dto'; @Injectable() export class ExchangeApiKeysService { @@ -20,8 +19,7 @@ export class ExchangeApiKeysService { private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, private readonly userRepository: UserRepository, private readonly aesEncryptionService: AesEncryptionService, - @Inject(forwardRef(() => ExchangeRouterService)) - private readonly exchangeRouterService: ExchangeRouterService, + private readonly exchangeClientFactory: ExchangeClientFactory, ) {} async enroll(input: { @@ -43,18 +41,14 @@ export class ExchangeApiKeysService { const currentKeys = await this.exchangeApiKeysRepository.findOneByUserId(userId); if (currentKeys) { - throw new ActiveExchangeApiKeyExistsError(userId); + throw new ActiveExchangeApiKeyExistsError(userId, exchangeName); } - const creds = { apiKey, secret: secretKey }; - if ( - !this.exchangeRouterService.checkRequiredCredentials(exchangeName, creds) - ) { - throw new IncompleteKeySuppliedError(exchangeName); - } - - const hasRequiredAccess = - await this.exchangeRouterService.checkRequiredAccess(exchangeName, creds); + const client = await this.exchangeClientFactory.create(exchangeName, { + apiKey, + secretKey, + }); + const hasRequiredAccess = await client.checkRequiredAccess(); if (!hasRequiredAccess) { throw new KeyAuthorizationError(exchangeName); } @@ -74,27 +68,19 @@ export class ExchangeApiKeysService { ]); enrolledKey.apiKey = encryptedApiKey; enrolledKey.secretKey = encryptedSecretKey; - enrolledKey.updatedAt = new Date(); - - await this.exchangeApiKeysRepository.upsert(enrolledKey, [ - 'userId', - 'exchangeName', - ]); + await this.exchangeApiKeysRepository.createUnique(enrolledKey); return enrolledKey; } - async retrieve( - userId: number, - exchangeName: string, - ): Promise<{ id: number; apiKey: string; secretKey: string }> { - const entity = - await this.exchangeApiKeysRepository.findOneByUserAndExchange( - userId, - exchangeName, - ); + async retrieve(userId: number): Promise<{ + exchangeName: string; + apiKey: string; + secretKey: string; + } | null> { + const entity = await this.exchangeApiKeysRepository.findOneByUserId(userId); if (!entity) { - throw new ExchangeApiKeyNotFoundError(userId, exchangeName); + return null; } const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ @@ -103,9 +89,29 @@ export class ExchangeApiKeysService { ]); return { - id: entity.id, + exchangeName: entity.exchangeName, apiKey: decryptedApiKey.toString(), secretKey: decryptedSecretKey.toString(), }; } + + async retrievedEnrolledApiKey( + userId: number, + ): Promise { + const enrolledKey = + await this.exchangeApiKeysRepository.findOneByUserId(userId); + + if (!enrolledKey) { + return null; + } + + const decodedApiKey = await this.aesEncryptionService.decrypt( + enrolledKey.apiKey, + ); + + return { + exchangeName: enrolledKey.exchangeName, + apiKey: decodedApiKey.toString(), + }; + } } 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 index 7fca563319..ddc664fdc8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts @@ -1,14 +1,9 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; -import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; - -import { ExchangeRouterService } from './exchange.router.service'; -import { GateExchangeService } from './gate-exchange.service'; -import { MexcExchangeService } from './mexc-exchange.service'; +import { ExchangeClientFactory } from './exchange-client.factory'; @Module({ - imports: [forwardRef(() => ExchangeApiKeysModule)], - providers: [MexcExchangeService, GateExchangeService, ExchangeRouterService], - exports: [ExchangeRouterService], + providers: [ExchangeClientFactory], + exports: [ExchangeClientFactory], }) export class ExchangeModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts deleted file mode 100644 index 3eb80aa4f8..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { ExchangeCredentials, ExchangeService } from './exchange.service'; -import { GateExchangeService } from './gate-exchange.service'; -import { MexcExchangeService } from './mexc-exchange.service'; - -@Injectable() -export class ExchangeRouterService { - private servicesMap: Record; - - constructor( - private readonly mexcService: MexcExchangeService, - private readonly gateService: GateExchangeService, - ) { - this.servicesMap = { - mexc: this.mexcService, - gate: this.gateService, - }; - } - - checkRequiredCredentials( - exchangeName: string, - creds: ExchangeCredentials, - ): boolean { - const service = this.servicesMap[exchangeName.toLowerCase()]; - if (!service) { - throw new Error(`Exchange service not found for ${exchangeName}`); - } - return service.checkRequiredCredentials(creds); - } - - async checkRequiredAccess( - exchangeName: string, - creds: ExchangeCredentials, - ): Promise { - const service = this.servicesMap[exchangeName.toLowerCase()]; - if (!service) { - throw new Error(`Exchange service not found for ${exchangeName}`); - } - return service.checkRequiredAccess(creds); - } - - async getAccountBalance( - exchangeName: string, - userId: number, - asset?: string, - ): Promise { - const service = this.servicesMap[exchangeName.toLowerCase()]; - if (!service) { - throw new Error(`Exchange service not found for ${exchangeName}`); - } - return service.getAccountBalance(userId, asset); - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts deleted file mode 100644 index 41bfb7721e..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type ExchangeCredentials = { apiKey: string; secret: string }; - -export abstract class ExchangeService { - abstract checkRequiredCredentials(creds: ExchangeCredentials): boolean; - - abstract checkRequiredAccess(creds: ExchangeCredentials): Promise; - - abstract getAccountBalance(userId: number, asset?: string): Promise; -} 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..6be1e45eaa --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -0,0 +1,172 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import appLogger from '@/logger'; +import Environment from '@/utils/environment'; + +import { ExchangeApiClientError } from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; + +const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; +const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; + +function 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'); +} + +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 = appLogger.child({ + context: 'GateExchangeClient', + exchange: 'gate', + }); + + 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 = signGateRequest( + method, + `/api/v4${path}`, + query, + body, + this.secretKey, + ts, + ); + + try { + const res = await fetch(`${this.apiBaseUrl}${path}`, { + method, + headers: { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (res.ok) return true; + if (res.status === 401 || res.status === 403) return false; + this.logger.warn('Gate access check failed', { + status: res.status, + statusText: res.statusText, + }); + throw new ExchangeApiClientError( + `Gate access check failed with status ${res.status}`, + ); + } catch (err) { + const message: string = 'Gate network error during access check'; + this.logger.error(message, { + error: err.message, + }); + throw new ExchangeApiClientError(`${message}: ${(err as Error).message}`); + } + } + + 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 = signGateRequest( + method, + requestPath, + query, + body, + this.secretKey, + ts, + ); + const url = `${this.apiBaseUrl}${path}?${query}`; + + try { + const res = await fetch(url, { + method, + headers: { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) return 0; + this.logger.warn('Gate balance fetch failed', { + status: res.status, + statusText: res.statusText, + asset, + }); + throw new ExchangeApiClientError( + `Gate balance fetch failed with status ${res.status}`, + ); + } + + 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; + } catch (err) { + const message: string = 'Gate network error during balance fetch'; + this.logger.error(message, { + error: (err as Error)?.message, + asset, + }); + throw new ExchangeApiClientError(`${message}: ${err.message}`); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts deleted file mode 100644 index ea1b6abd8b..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { createHash, createHmac } from 'node:crypto'; - -import { Inject, Injectable, forwardRef } from '@nestjs/common'; - -import { StakingConfigService } from '@/config'; -import logger from '@/logger'; -import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; -import Environment from '@/utils/environment'; - -import { ExchangeCredentials, ExchangeService } from './exchange.service'; - -const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; -const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; - -function 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'); -} - -@Injectable() -export class GateExchangeService extends ExchangeService { - readonly id = 'gate' as const; - private readonly logger = logger.child({ context: GateExchangeService.name }); - private readonly apiBaseUrl = - process.env.GATE_API_BASE_URL && process.env.GATE_API_BASE_URL.length > 0 - ? process.env.GATE_API_BASE_URL - : Environment.isDevelopment() - ? DEVELOP_GATE_API_BASE_URL - : GATE_API_BASE_URL; - - constructor( - @Inject(forwardRef(() => ExchangeApiKeysService)) - private readonly exchangeApiKeysService: ExchangeApiKeysService, - private readonly stakingConfigService: StakingConfigService, - ) { - super(); - } - - checkRequiredCredentials(creds: ExchangeCredentials): boolean { - return Boolean(creds?.apiKey && creds?.secret); - } - - async checkRequiredAccess(creds: ExchangeCredentials): Promise { - const method = 'GET'; - const path = '/spot/accounts'; - const query = ''; - const body = ''; - const ts = String(Math.floor(Date.now() / 1000)); - const signature = signGateRequest( - method, - `/api/v4${path}`, - query, - body, - creds.secret, - ts, - ); - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(`${this.apiBaseUrl}${path}`, { - method, - headers: { - KEY: creds.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: controller.signal, - } as RequestInit); - if (!res.ok) { - const text = await res.text(); - console.warn('Gate access check http error', { - status: res.status, - body: text, - }); - return false; - } - return true; - } catch (error) { - this.logger.error('Gate access check failed', { - error, - }); - return false; - } finally { - clearTimeout(timeout); - } - } - - async getAccountBalance(userId: number, asset = 'HMT'): Promise { - const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); - - 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 = signGateRequest( - method, - requestPath, - query, - body, - creds.secretKey, - ts, - ); - const url = `${this.apiBaseUrl}${path}?${query}`; - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(url, { - method, - headers: { - KEY: creds.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: controller.signal, - } as RequestInit); - if (!res.ok) { - const text = await res.text(); - this.logger.error('Gate getAccountBalance http error', { - status: res.status, - body: text, - }); - 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; - } catch (error) { - this.logger.error('Gate getAccountBalance failed', { - error, - }); - return 0; - } finally { - clearTimeout(timeout); - } - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts index 49bc0304e4..083c7dd8bd 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts @@ -1,4 +1,2 @@ export { ExchangeModule } from './exchange.module'; -export { ExchangeRouterService } from './exchange.router.service'; -export { MexcExchangeService } from './mexc-exchange.service'; -export { GateExchangeService } from './gate-exchange.service'; +export { ExchangeClientFactory } from './exchange-client.factory'; 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..de191b025a --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -0,0 +1,121 @@ +import { createHmac } from 'node:crypto'; + +import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import appLogger from '@/logger'; + +import { ExchangeApiClientError } from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; + +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 = appLogger.child({ + context: 'MexcExchangeClient', + exchange: 'mexc', + }); + 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 timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = this.signQuery(query); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { 'X-MEXC-APIKEY': this.apiKey }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (res.ok) return true; + if (res.status === 401 || res.status === 403) return false; + this.logger.warn('MEXC access check failed', { + status: res.status, + statusText: res.statusText, + }); + throw new ExchangeApiClientError( + `MEXC access check failed with status ${res.status}`, + ); + } catch (err) { + const message: string = 'MEXC network error during access check'; + this.logger.error(message, { + error: err.message, + }); + throw new ExchangeApiClientError(`${message}: ${err.message}`); + } + } + + async getAccountBalance(asset: string): Promise { + const path = '/account'; + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = this.signQuery(query); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { + 'X-MEXC-APIKEY': this.apiKey, + }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) return 0; + this.logger.warn('MEXC balance fetch failed', { + status: res.status, + statusText: res.statusText, + asset, + }); + throw new ExchangeApiClientError( + `MEXC balance fetch failed with status ${res.status}`, + ); + } + + 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; + } catch (err) { + const message: string = 'MEXC network error during balance fetch'; + this.logger.error(message, { + error: err.message, + asset, + }); + throw new ExchangeApiClientError(`${message}: ${err.message}`); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts deleted file mode 100644 index 65b976f971..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createHmac } from 'node:crypto'; - -import { Inject, Injectable, forwardRef } from '@nestjs/common'; - -import { StakingConfigService } from '@/config'; -import logger from '@/logger'; -import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; - -import { ExchangeCredentials, ExchangeService } from './exchange.service'; - -const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; - -@Injectable() -export class MexcExchangeService extends ExchangeService { - readonly id = 'mexc' as const; - readonly recvWindow = 5000; - private readonly logger = logger.child({ context: MexcExchangeService.name }); - - constructor( - @Inject(forwardRef(() => ExchangeApiKeysService)) - private readonly exchangeApiKeysService: ExchangeApiKeysService, - private readonly stakingConfigService: StakingConfigService, - ) { - super(); - } - - checkRequiredCredentials(creds: ExchangeCredentials): boolean { - return Boolean(creds?.apiKey && creds?.secret); - } - - async checkRequiredAccess(creds: ExchangeCredentials): Promise { - const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = createHmac('sha256', creds.secret) - .update(query) - .digest('hex'); - const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(url, { - method: 'GET', - headers: { 'X-MEXC-APIKEY': creds.apiKey }, - signal: controller.signal, - } as RequestInit); - return res.ok; - } catch (error) { - this.logger.error('MEXC access check failed', { - error, - }); - return false; - } finally { - clearTimeout(timeout); - } - } - - async getAccountBalance(userId: number, asset = 'HMT'): Promise { - const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); - - const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = createHmac('sha256', creds.secretKey) - .update(query) - .digest('hex'); - const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(url, { - method: 'GET', - headers: { - 'X-MEXC-APIKEY': creds.apiKey, - }, - signal: controller.signal, - } as RequestInit); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Http error ${res.status}: ${text}`); - } - 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; - } catch (error) { - this.logger.error('MEXC getAccountBalance failed', { - error, - }); - return 0; - } finally { - clearTimeout(timeout); - } - } -} 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; +} From c3a6886fd1e17a02db72723708bd3d08bfc1a19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 15:46:14 +0100 Subject: [PATCH 04/10] simplify error handling in Gate and Mexc --- .../src/modules/exchange/gate-exchange.client.ts | 10 ++-------- .../src/modules/exchange/mexc-exchange.client.ts | 10 ++-------- 2 files changed, 4 insertions(+), 16 deletions(-) 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 index 6be1e45eaa..eb100f89f9 100644 --- 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 @@ -82,14 +82,11 @@ export class GateExchangeClient implements ExchangeClient { } as RequestInit); if (res.ok) return true; - if (res.status === 401 || res.status === 403) return false; this.logger.warn('Gate access check failed', { status: res.status, statusText: res.statusText, }); - throw new ExchangeApiClientError( - `Gate access check failed with status ${res.status}`, - ); + return false; } catch (err) { const message: string = 'Gate network error during access check'; this.logger.error(message, { @@ -129,15 +126,12 @@ export class GateExchangeClient implements ExchangeClient { } as RequestInit); if (!res.ok) { - if (res.status === 401 || res.status === 403) return 0; this.logger.warn('Gate balance fetch failed', { status: res.status, statusText: res.statusText, asset, }); - throw new ExchangeApiClientError( - `Gate balance fetch failed with status ${res.status}`, - ); + return 0; } const data = (await res.json()) as Array<{ 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 index de191b025a..21d13f588c 100644 --- 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 @@ -54,14 +54,11 @@ export class MexcExchangeClient implements ExchangeClient { } as RequestInit); if (res.ok) return true; - if (res.status === 401 || res.status === 403) return false; this.logger.warn('MEXC access check failed', { status: res.status, statusText: res.statusText, }); - throw new ExchangeApiClientError( - `MEXC access check failed with status ${res.status}`, - ); + return false; } catch (err) { const message: string = 'MEXC network error during access check'; this.logger.error(message, { @@ -88,15 +85,12 @@ export class MexcExchangeClient implements ExchangeClient { } as RequestInit); if (!res.ok) { - if (res.status === 401 || res.status === 403) return 0; this.logger.warn('MEXC balance fetch failed', { status: res.status, statusText: res.statusText, asset, }); - throw new ExchangeApiClientError( - `MEXC balance fetch failed with status ${res.status}`, - ); + return 0; } const data = (await res.json()) as { From 65fe558596fcf1544a41a83212f4138bc509d92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 16:02:27 +0100 Subject: [PATCH 05/10] add AES encryption key to .env.example and improve error handling in getStakedBalance method --- .../apps/reputation-oracle/server/.env.example | 3 +++ .../exchange-api-keys.error-filter.ts | 2 +- .../server/src/modules/web3/web3.service.ts | 18 +++++++++++------- 3 files changed, 15 insertions(+), 8 deletions(-) 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/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 index 38aa4e7943..bb21e69d46 100644 --- 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 @@ -7,6 +7,7 @@ import { import { Request, Response } from 'express'; import logger from '@/logger'; +import { UserNotFoundError } from '@/modules/user'; import { ExchangeApiKeyNotFoundError, @@ -15,7 +16,6 @@ import { ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; import { ExchangeApiClientError } from '../exchange/errors'; -import { UserNotFoundError } from '../user'; @Catch( UserNotFoundError, 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 58c5801ef8..dcaecb47c9 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 @@ -108,14 +108,18 @@ export class Web3Service { } async getStakedBalance(address: string): Promise { - const chainId = this.web3ConfigService.reputationNetworkChainId; - const provider = this.getSigner(chainId).provider; + 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 stakingClient = await StakingClient.build(provider); + const stakerInfo = await stakingClient.getStakerInfo(address); - const total = - (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); - return Number(ethers.formatEther(total)); + const total = + (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); + return Number(ethers.formatEther(total)); + } catch (error) { + throw new Error(`Failed to fetch staked balance: ${error.message}`); + } } } From b58f09f49985b8865d783f3e6465fd506717fe73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 16:19:19 +0100 Subject: [PATCH 06/10] add staking eligibility env var to enable or disable this functionality --- .../server/src/config/staking-config.service.ts | 14 +++++++++++++- .../server/src/modules/auth/auth.service.ts | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 index 94b5d6a414..174d3ed5d1 100644 --- a/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts @@ -10,7 +10,7 @@ export class StakingConfigService { * Default: 'HMT' */ get asset(): string { - return this.configService.get('STAKING_ASSET') || 'HMT'; + return this.configService.get('STAKING_ASSET', 'HMT'); } /** @@ -28,4 +28,16 @@ export class StakingConfigService { 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/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index 8c583e5eb7..3c8aa220b0 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 @@ -280,6 +280,8 @@ export class AuthService { private async calculateStakeEligible( userEntity: Web2UserEntity | UserEntity, ): Promise { + if (!this.stakingConfigService.eligibilityEnabled) return true; + const apiKeys = await this.exchangeApiKeysService.retrieve(userEntity.id); let inspectedStakeAmount = 0; From 853833a2ce67173e16b6e155704d2061f9d43d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 29 Oct 2025 18:03:47 +0100 Subject: [PATCH 07/10] Generate unit tests for AesEncriptionService, AuthService, GateExchangeClient, MexcExchangeClient and exchangeApiKeyService --- docker-setup/docker-compose.dev.yml | 2 +- .../server/scripts/setup-kv-store.ts | 5 +- .../server/scripts/setup-staking.ts | 5 +- .../server/src/app.module.ts | 2 + .../src/modules/auth/auth.service.spec.ts | 159 +++++++++++++ .../server/src/modules/auth/auth.service.ts | 6 +- .../encryption/aes-encryption.service.spec.ts | 85 +++++++ .../src/modules/encryption/fixtures/index.ts | 10 +- .../exchange-api-keys.controller.ts | 25 ++- .../exchange-api-keys.service.spec.ts | 212 ++++++++++++++++++ .../exchange-api-keys.service.ts | 27 +-- .../exchange-api-keys/fixtures/index.ts | 25 +++ .../src/modules/exchange/fixtures/exchange.ts | 30 +++ .../src/modules/exchange/fixtures/index.ts | 1 + .../exchange/gate-exchange.client.spec.ts | 137 +++++++++++ .../modules/exchange/gate-exchange.client.ts | 30 +-- .../exchange/mexc-exchange.client.spec.ts | 178 +++++++++++++++ .../modules/exchange/mexc-exchange.client.ts | 30 +-- .../server/src/modules/web3/web3.service.ts | 8 +- 19 files changed, 903 insertions(+), 74 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts diff --git a/docker-setup/docker-compose.dev.yml b/docker-setup/docker-compose.dev.yml index 456c82f74e..e7555180cb 100644 --- a/docker-setup/docker-compose.dev.yml +++ b/docker-setup/docker-compose.dev.yml @@ -146,7 +146,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql + - postgres-data:/var/lib/postgresql/data environment: POSTGRES_USER: *postgres_user POSTGRES_PASSWORD: *postgres_password diff --git a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts index 5165db585c..e64b8aaf7f 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts @@ -1,6 +1,6 @@ import { KVStoreClient, KVStoreKeys, Role } from '@human-protocol/sdk'; import * as dotenv from 'dotenv'; -import { Wallet, ethers, NonceManager } from 'ethers'; +import { Wallet, ethers } from 'ethers'; import * as Minio from 'minio'; const isLocalEnv = process.env.LOCAL === 'true'; @@ -105,8 +105,7 @@ async function setup(): Promise { } const provider = new ethers.JsonRpcProvider(RPC_URL); - const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); - const wallet = new NonceManager(baseWallet); + const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); const kvStoreClient = await KVStoreClient.build(wallet); diff --git a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts index 71f6c4c57d..e19787ae8b 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts @@ -6,7 +6,7 @@ import { StakingClient, } from '@human-protocol/sdk'; import * as dotenv from 'dotenv'; -import { ethers, NonceManager, Wallet } from 'ethers'; +import { Wallet, ethers } from 'ethers'; dotenv.config({ path: '.env.local' }); @@ -26,8 +26,7 @@ export async function setup(): Promise { const { hmtAddress: hmtTokenAddress, stakingAddress } = NETWORKS[ ChainId.LOCALHOST ] as NetworkData; - const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); - const wallet = new NonceManager(baseWallet); + const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); const hmtContract = HMToken__factory.connect(hmtTokenAddress, wallet); const hmtTx = await hmtContract.approve(stakingAddress, 1); 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/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 3c8aa220b0..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 @@ -253,7 +253,7 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } - const stakeEligible = await this.calculateStakeEligible(userEntity); + const stakeEligible = await this.checkStakeEligible(userEntity); const jwtPayload = { email: userEntity.email, @@ -277,7 +277,7 @@ export class AuthService { return this.generateTokens(userEntity.id, jwtPayload); } - private async calculateStakeEligible( + private async checkStakeEligible( userEntity: Web2UserEntity | UserEntity, ): Promise { if (!this.stakingConfigService.eligibilityEnabled) return true; @@ -304,7 +304,7 @@ export class AuthService { this.logger.warn('Failed to query exchange balance; continuing', { userId: userEntity.id, exchangeName: apiKeys.exchangeName, - error: (err as Error)?.message, + error: err, }); } } 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/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts index a2b44a974e..656d5464ac 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -2,5 +2,13 @@ import { faker } from '@faker-js/faker'; export const mockEncryptionConfigService = { // 32-byte key for AES-256-GCM tests - aesEncryptionKey: faker.string.sample(32), + 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-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index ceb1de5de0..5648537f40 100644 --- 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 @@ -16,7 +16,6 @@ import { ApiOperation, ApiResponse, ApiTags, - getSchemaPath, } from '@nestjs/swagger'; import type { RequestWithUser } from '@/common/types'; @@ -29,6 +28,7 @@ import { 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'; @@ -48,18 +48,23 @@ export class ExchangeApiKeysController { }) @ApiResponse({ status: 200, - schema: { - nullable: true, - allOf: [{ $ref: getSchemaPath(EnrolledApiKeyDto) }], - }, + type: EnrolledApiKeyDto, }) @Get('/') async retrieveEnrolledApiKeys( @Req() request: RequestWithUser, - ): Promise { + ): Promise { const userId = request.user.id; - return this.exchangeApiKeysService.retrievedEnrolledApiKey(userId); + const apiKey = await this.exchangeApiKeysService.retrieve(userId); + if (!apiKey) { + throw new ExchangeApiKeyNotFoundError(userId); + } + + return { + exchangeName: apiKey.exchangeName, + apiKey: apiKey.apiKey, + }; } @ApiOperation({ @@ -114,6 +119,10 @@ export class ExchangeApiKeysController { throw new ForbiddenException(); } - return this.exchangeApiKeysService.retrieve(request.user.id); + 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.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 index c9c3db7d6b..f7d785a272 100644 --- 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 @@ -3,6 +3,7 @@ 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 { @@ -10,16 +11,14 @@ import { ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; -import { UserNotFoundError, UserRepository } from '../user'; -import { EnrolledApiKeyDto } from './exchange-api-keys.dto'; @Injectable() export class ExchangeApiKeysService { constructor( - private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, - private readonly userRepository: UserRepository, private readonly aesEncryptionService: AesEncryptionService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, private readonly exchangeClientFactory: ExchangeClientFactory, + private readonly userRepository: UserRepository, ) {} async enroll(input: { @@ -94,24 +93,4 @@ export class ExchangeApiKeysService { secretKey: decryptedSecretKey.toString(), }; } - - async retrievedEnrolledApiKey( - userId: number, - ): Promise { - const enrolledKey = - await this.exchangeApiKeysRepository.findOneByUserId(userId); - - if (!enrolledKey) { - return null; - } - - const decodedApiKey = await this.aesEncryptionService.decrypt( - enrolledKey.apiKey, - ); - - return { - exchangeName: enrolledKey.exchangeName, - apiKey: decodedApiKey.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/fixtures/exchange.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts new file mode 100644 index 0000000000..d1fabc9c61 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts @@ -0,0 +1,30 @@ +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(), + })), + }; +} +import { faker } from '@faker-js/faker'; + +import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; + +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..9ac6a7a961 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts @@ -0,0 +1,137 @@ +jest.mock('@/logger'); + +import { faker } from '@faker-js/faker'; + +import { ExchangeApiClientError } from './errors'; +import { generateGateAccountBalance } from './fixtures'; +import { GateExchangeClient } from './gate-exchange.client'; + +describe('GateExchangeClient', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + 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', () => { + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + } as Response); + const result = await client.checkRequiredAccess(); + 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 }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + const result = await client.checkRequiredAccess(); + 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 }); + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); + + describe('getAccountBalance', () => { + 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 }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + const result = await client.getAccountBalance(asset); + 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 }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => generateGateAccountBalance(['OTHER']), + } as Response); + const result = await client.getAccountBalance(asset); + 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]); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => balanceFixture, + } as Response); + + const result = await client.getAccountBalance(asset); + 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 }); + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); +}); 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 index eb100f89f9..655f1395d7 100644 --- 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 @@ -33,13 +33,13 @@ export class GateExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'gate'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs?: number; + private readonly timeoutMs: number; private readonly apiBaseUrl = Environment.isDevelopment() ? DEVELOP_GATE_API_BASE_URL : GATE_API_BASE_URL; private readonly logger = appLogger.child({ - context: 'GateExchangeClient', - exchange: 'gate', + context: GateExchangeClient.name, + exchange: this.id, }); constructor( @@ -51,7 +51,7 @@ export class GateExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs; + this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; } async checkRequiredAccess(): Promise { @@ -78,21 +78,21 @@ export class GateExchangeClient implements ExchangeClient { Timestamp: ts, Accept: 'application/json', }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (res.ok) return true; - this.logger.warn('Gate access check failed', { + this.logger.debug('Gate access check failed', { status: res.status, statusText: res.statusText, }); return false; - } catch (err) { - const message: string = 'Gate network error during access check'; + } catch (error) { + const message: string = 'Failed to check access for Gate'; this.logger.error(message, { - error: err.message, + error, }); - throw new ExchangeApiClientError(`${message}: ${(err as Error).message}`); + throw new ExchangeApiClientError(message); } } @@ -122,7 +122,7 @@ export class GateExchangeClient implements ExchangeClient { Timestamp: ts, Accept: 'application/json', }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (!res.ok) { @@ -154,13 +154,13 @@ export class GateExchangeClient implements ExchangeClient { const entry = data.find((d) => d.currency === asset); return entry ? normalize(entry) : 0; - } catch (err) { - const message: string = 'Gate network error during balance fetch'; + } catch (error) { + const message: string = 'Failed to get account balance for Gate'; this.logger.error(message, { - error: (err as Error)?.message, + error, asset, }); - throw new ExchangeApiClientError(`${message}: ${err.message}`); + throw new ExchangeApiClientError(message); } } } 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..d3fece0bc3 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts @@ -0,0 +1,178 @@ +jest.mock('@/logger'); + +import { faker } from '@faker-js/faker'; + +import { ExchangeApiClientError } from './errors'; +import { generateMexcAccountBalance } from './fixtures'; +import { MexcExchangeClient } from './mexc-exchange.client'; + +describe('MexcExchangeClient', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + 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 client = new MexcExchangeClient( + { apiKey, secretKey }, + { timeoutMs: 1234 }, + ); + + expect(client).toBeDefined(); + expect(client['apiKey']).toBe(apiKey); + expect(client['secretKey']).toBe(secretKey); + expect(client['timeoutMs']).toBe(1234); + }); + }); + + describe('signQuery', () => { + it('returns a valid signature', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + const query = 'timestamp=123&recvWindow=5000'; + const signature = client['signQuery'](query); + + expect(typeof signature).toBe('string'); + expect(signature.length).toBeGreaterThan(0); + }); + }); + + describe('checkRequiredAccess', () => { + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + } as Response); + + const result = await client.checkRequiredAccess(); + 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 }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + + const result = await client.checkRequiredAccess(); + 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 }); + + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + + await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); + + describe('getAccountBalance', () => { + 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 }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + + const result = await client.getAccountBalance(asset); + 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 }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => generateMexcAccountBalance(['OTHER']), + } as Response); + + const result = await client.getAccountBalance(asset); + 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]); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => balanceFixture, + } as Response); + + const result = await client.getAccountBalance(asset); + 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 }); + + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + + await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); +}); 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 index 21d13f588c..466cd548ee 100644 --- 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 @@ -16,10 +16,10 @@ export class MexcExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'mexc'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs?: number; + private readonly timeoutMs: number; private readonly logger = appLogger.child({ - context: 'MexcExchangeClient', - exchange: 'mexc', + context: MexcExchangeClient.name, + exchange: this.id, }); readonly recvWindow = 5000; @@ -32,7 +32,7 @@ export class MexcExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs; + this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; } private signQuery(query: string): string { @@ -50,21 +50,21 @@ export class MexcExchangeClient implements ExchangeClient { const res = await fetch(url, { method: 'GET', headers: { 'X-MEXC-APIKEY': this.apiKey }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (res.ok) return true; - this.logger.warn('MEXC access check failed', { + this.logger.debug('MEXC access check failed', { status: res.status, statusText: res.statusText, }); return false; - } catch (err) { - const message: string = 'MEXC network error during access check'; + } catch (error) { + const message: string = 'Failed to check access for MEXC'; this.logger.error(message, { - error: err.message, + error, }); - throw new ExchangeApiClientError(`${message}: ${err.message}`); + throw new ExchangeApiClientError(message); } } @@ -81,7 +81,7 @@ export class MexcExchangeClient implements ExchangeClient { headers: { 'X-MEXC-APIKEY': this.apiKey, }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (!res.ok) { @@ -103,13 +103,13 @@ export class MexcExchangeClient implements ExchangeClient { (parseFloat(entry.free || '0') || 0) + (parseFloat(entry.locked || '0') || 0); return total; - } catch (err) { - const message: string = 'MEXC network error during balance fetch'; + } catch (error) { + const message: string = 'Failed to get account balance for MEXC'; this.logger.error(message, { - error: err.message, + error, asset, }); - throw new ExchangeApiClientError(`${message}: ${err.message}`); + 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 dcaecb47c9..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 @@ -4,6 +4,7 @@ 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; @@ -119,7 +123,9 @@ export class Web3Service { (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); return Number(ethers.formatEther(total)); } catch (error) { - throw new Error(`Failed to fetch staked balance: ${error.message}`); + const message = 'Failed to fetch staked balance'; + this.logger.error(message, { address, error }); + throw new Error(message); } } } From e79625324a420c659476d9b91f04439d27073d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 30 Oct 2025 10:10:34 +0100 Subject: [PATCH 08/10] Refactor GateExchangeClient and MexcExchangeClient to use fetchWithHandling utility for improved error handling and code consistency --- .../modules/exchange/gate-exchange.client.ts | 136 ++++++++---------- .../modules/exchange/mexc-exchange.client.ts | 99 ++++++------- .../server/src/modules/exchange/utils.ts | 27 ++++ 3 files changed, 130 insertions(+), 132 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts 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 index 655f1395d7..0f87574d4b 100644 --- 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 @@ -1,7 +1,7 @@ import { createHash, createHmac } from 'node:crypto'; -import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; -import appLogger from '@/logger'; +import { type SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; import Environment from '@/utils/environment'; import { ExchangeApiClientError } from './errors'; @@ -10,6 +10,7 @@ import type { ExchangeClientCredentials, ExchangeClientOptions, } from './types'; +import { fetchWithHandling } from './utils'; const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; @@ -33,11 +34,11 @@ export class GateExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'gate'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs: number; + private readonly timeoutMs?: number; private readonly apiBaseUrl = Environment.isDevelopment() ? DEVELOP_GATE_API_BASE_URL : GATE_API_BASE_URL; - private readonly logger = appLogger.child({ + private readonly logger = logger.child({ context: GateExchangeClient.name, exchange: this.id, }); @@ -51,7 +52,7 @@ export class GateExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; + this.timeoutMs = options?.timeoutMs; } async checkRequiredAccess(): Promise { @@ -69,31 +70,25 @@ export class GateExchangeClient implements ExchangeClient { ts, ); - try { - const res = await fetch(`${this.apiBaseUrl}${path}`, { - method, - headers: { - KEY: this.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); + const res = await fetchWithHandling( + this.id, + `${this.apiBaseUrl}${path}`, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); - if (res.ok) return true; - this.logger.debug('Gate access check failed', { - status: res.status, - statusText: res.statusText, - }); - return false; - } catch (error) { - const message: string = 'Failed to check access for Gate'; - this.logger.error(message, { - error, - }); - throw new ExchangeApiClientError(message); - } + if (res.ok) return true; + this.logger.debug('Gate access check failed', { + status: res.status, + statusText: res.statusText, + }); + return false; } async getAccountBalance(asset: string): Promise { @@ -113,54 +108,47 @@ export class GateExchangeClient implements ExchangeClient { ); const url = `${this.apiBaseUrl}${path}?${query}`; - try { - const res = await fetch(url, { - method, - headers: { - KEY: this.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); - - if (!res.ok) { - this.logger.warn('Gate balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); - 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 res = await fetchWithHandling( + this.id, + url, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); - const entry = data.find((d) => d.currency === asset); - return entry ? normalize(entry) : 0; - } catch (error) { - const message: string = 'Failed to get account balance for Gate'; - this.logger.error(message, { - error, + if (!res.ok) { + this.logger.warn('Gate balance fetch failed', { + status: res.status, + statusText: res.statusText, asset, }); - throw new ExchangeApiClientError(message); + 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; } } 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 index 466cd548ee..dabc6b2ece 100644 --- 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 @@ -1,7 +1,7 @@ import { createHmac } from 'node:crypto'; -import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; -import appLogger from '@/logger'; +import { type SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; import { ExchangeApiClientError } from './errors'; import type { @@ -9,6 +9,7 @@ import type { ExchangeClientCredentials, ExchangeClientOptions, } from './types'; +import { fetchWithHandling } from './utils'; const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; @@ -16,8 +17,8 @@ export class MexcExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'mexc'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs: number; - private readonly logger = appLogger.child({ + private readonly timeoutMs?: number; + private readonly logger = logger.child({ context: MexcExchangeClient.name, exchange: this.id, }); @@ -32,7 +33,7 @@ export class MexcExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; + this.timeoutMs = options?.timeoutMs; } private signQuery(query: string): string { @@ -46,26 +47,19 @@ export class MexcExchangeClient implements ExchangeClient { const signature = this.signQuery(query); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { 'X-MEXC-APIKEY': this.apiKey }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); - - if (res.ok) return true; - this.logger.debug('MEXC access check failed', { - status: res.status, - statusText: res.statusText, - }); - return false; - } catch (error) { - const message: string = 'Failed to check access for MEXC'; - this.logger.error(message, { - error, - }); - throw new ExchangeApiClientError(message); - } + const res = await fetchWithHandling( + this.id, + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (res.ok) return true; + this.logger.debug('MEXC access check failed', { + status: res.status, + statusText: res.statusText, + }); + return false; } async getAccountBalance(asset: string): Promise { @@ -75,41 +69,30 @@ export class MexcExchangeClient implements ExchangeClient { const signature = this.signQuery(query); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { - 'X-MEXC-APIKEY': this.apiKey, - }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); - - if (!res.ok) { - this.logger.warn('MEXC balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); - 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; - } catch (error) { - const message: string = 'Failed to get account balance for MEXC'; - this.logger.error(message, { - error, + const res = await fetchWithHandling( + this.id, + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (!res.ok) { + this.logger.warn('MEXC balance fetch failed', { + status: res.status, + statusText: res.statusText, asset, }); - throw new ExchangeApiClientError(message); + 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; } } 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..ab4d3631ec --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts @@ -0,0 +1,27 @@ +import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import Logger from '@/logger'; + +import { ExchangeApiClientError } from './errors'; + +export async function fetchWithHandling( + id: SupportedExchange, + 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 fetch ${id.toUpperCase()}`; + logger.error(message, { + error, + }); + throw new ExchangeApiClientError(message); + } +} From d9f9dd4ecc0a653c180f6538c6fb86b4fe17d93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 30 Oct 2025 11:53:33 +0100 Subject: [PATCH 09/10] Add unit tests for staking requirement in HUMAN App --- .../exchange-api-keys.controller.ts | 10 +-- .../exchange-api-keys.service.ts | 19 ++--- .../model/exchange-api-keys.model.ts | 10 --- .../spec/exchange-api-keys.controller.spec.ts | 85 +++++++++++++++++++ .../spec/exchange-api-keys.fixtures.ts | 33 +++++++ .../spec/exchange-api-keys.service.mock.ts | 10 +++ .../spec/exchange-api-keys.service.spec.ts | 73 ++++++++++++++++ .../spec/job-assignment.controller.spec.ts | 55 ++++++++++++ .../spec/jobs-discovery.controller.spec.ts | 19 ++++- 9 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts 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 index a827806b04..620001c690 100644 --- 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 @@ -20,10 +20,8 @@ import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange import { InjectMapper } from '@automapper/nestjs'; import { Mapper } from '@automapper/core'; import { - DeleteExchangeApiKeysCommand, EnrollExchangeApiKeysCommand, EnrollExchangeApiKeysDto, - RetrieveExchangeApiKeysCommand, RetrieveExchangeApiKeysResponse, } from './model/exchange-api-keys.model'; @@ -61,9 +59,7 @@ export class ExchangeApiKeysController { @HttpCode(204) @Delete('/') async delete(@Request() req: RequestWithUser): Promise { - const command = new DeleteExchangeApiKeysCommand(); - command.token = req.token; - await this.service.delete(command); + await this.service.delete(req.token); } @ApiOperation({ @@ -73,8 +69,6 @@ export class ExchangeApiKeysController { async retrieve( @Request() req: RequestWithUser, ): Promise { - const command = new RetrieveExchangeApiKeysCommand(); - command.token = req.token; - return this.service.retrieve(command); + return this.service.retrieve(req.token); } } 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 index 2f8d117366..f42d311e3b 100644 --- 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 @@ -1,32 +1,23 @@ import { Injectable } from '@nestjs/common'; import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; -import { InjectMapper } from '@automapper/nestjs'; -import { Mapper } from '@automapper/core'; import { - DeleteExchangeApiKeysCommand, EnrollExchangeApiKeysCommand, - RetrieveExchangeApiKeysCommand, RetrieveExchangeApiKeysResponse, } from './model/exchange-api-keys.model'; @Injectable() export class ExchangeApiKeysService { - constructor( - private readonly reputationOracle: ReputationOracleGateway, - @InjectMapper() private readonly mapper: Mapper, - ) {} + constructor(private readonly reputationOracle: ReputationOracleGateway) {} enroll(command: EnrollExchangeApiKeysCommand): Promise<{ id: number }> { return this.reputationOracle.enrollExchangeApiKeys(command); } - delete(command: DeleteExchangeApiKeysCommand): Promise { - return this.reputationOracle.deleteExchangeApiKeys(command.token); + delete(token: string): Promise { + return this.reputationOracle.deleteExchangeApiKeys(token); } - retrieve( - command: RetrieveExchangeApiKeysCommand, - ): Promise { - return this.reputationOracle.retrieveExchangeApiKeys(command.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 index 04179d3eeb..2a22018705 100644 --- 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 @@ -30,16 +30,6 @@ export class EnrollExchangeApiKeysData { secretKey: string; } -export class DeleteExchangeApiKeysCommand { - token: string; - exchangeName: string; -} - -export class RetrieveExchangeApiKeysCommand { - token: string; - exchangeName: 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/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/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: [], + }); }); }); }); From 5b92eac7f6bfb16204f21c8f31a4b56786514ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 31 Oct 2025 16:10:39 +0100 Subject: [PATCH 10/10] Refactor exchange client methods to improve error handling and code structure; add signature generation for API requests --- .../src/modules/exchange/fixtures/exchange.ts | 8 +- .../exchange/gate-exchange.client.spec.ts | 124 +++++++++++----- .../modules/exchange/gate-exchange.client.ts | 50 +++---- .../exchange/mexc-exchange.client.spec.ts | 133 +++++++++--------- .../modules/exchange/mexc-exchange.client.ts | 31 ++-- .../server/src/modules/exchange/utils.ts | 6 +- 6 files changed, 198 insertions(+), 154 deletions(-) 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 index d1fabc9c61..bb56ea256b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts @@ -1,3 +1,7 @@ +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'); @@ -9,6 +13,7 @@ export function generateGateAccountBalance(tokens: string[] = []) { freeze: faker.finance.amount(), })); } + export function generateMexcAccountBalance(tokens: string[] = []) { if (tokens.length === 0) { throw new Error('At least one token must be specified'); @@ -21,9 +26,6 @@ export function generateMexcAccountBalance(tokens: string[] = []) { })), }; } -import { faker } from '@faker-js/faker'; - -import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; export function generateExchangeName() { return faker.helpers.arrayElement(SUPPORTED_EXCHANGE_NAMES); 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 index 9ac6a7a961..3c35786cac 100644 --- 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 @@ -1,14 +1,57 @@ 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 { GateExchangeClient } from './gate-exchange.client'; +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', () => { @@ -33,88 +76,93 @@ describe('GateExchangeClient', () => { }); 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 }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - } as Response); + 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 }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); + 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 }); - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); + 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 }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); + 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 }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => generateGateAccountBalance(['OTHER']), - } as Response); + 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]); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => balanceFixture, - } as Response); + 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), @@ -126,12 +174,14 @@ describe('GateExchangeClient', () => { const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); const client = new GateExchangeClient({ apiKey, secretKey }); - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); + 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 index 0f87574d4b..c9d95b0ba8 100644 --- 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 @@ -12,23 +12,9 @@ import type { } from './types'; import { fetchWithHandling } from './utils'; -const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; -const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; - -function 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'); -} +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'; @@ -61,7 +47,7 @@ export class GateExchangeClient implements ExchangeClient { const query = ''; const body = ''; const ts = String(Math.floor(Date.now() / 1000)); - const signature = signGateRequest( + const signature = this.signGateRequest( method, `/api/v4${path}`, query, @@ -71,7 +57,6 @@ export class GateExchangeClient implements ExchangeClient { ); const res = await fetchWithHandling( - this.id, `${this.apiBaseUrl}${path}`, { KEY: this.apiKey, @@ -84,10 +69,6 @@ export class GateExchangeClient implements ExchangeClient { ); if (res.ok) return true; - this.logger.debug('Gate access check failed', { - status: res.status, - statusText: res.statusText, - }); return false; } @@ -98,7 +79,7 @@ export class GateExchangeClient implements ExchangeClient { const body = ''; const ts = String(Math.floor(Date.now() / 1000)); const requestPath = `/api/v4${path}`; - const signature = signGateRequest( + const signature = this.signGateRequest( method, requestPath, query, @@ -109,7 +90,6 @@ export class GateExchangeClient implements ExchangeClient { const url = `${this.apiBaseUrl}${path}?${query}`; const res = await fetchWithHandling( - this.id, url, { KEY: this.apiKey, @@ -122,11 +102,6 @@ export class GateExchangeClient implements ExchangeClient { ); if (!res.ok) { - this.logger.warn('Gate balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); return 0; } @@ -151,4 +126,19 @@ export class GateExchangeClient implements ExchangeClient { 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/mexc-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts index d3fece0bc3..8b35c5a95a 100644 --- 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 @@ -1,14 +1,22 @@ 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 } from './mexc-exchange.client'; +import { MexcExchangeClient, MEXC_API_BASE_URL } from './mexc-exchange.client'; describe('MexcExchangeClient', () => { + afterAll(() => { + nock.restore(); + }); + afterEach(() => { jest.resetAllMocks(); + nock.cleanAll(); }); describe('constructor', () => { @@ -21,16 +29,17 @@ describe('MexcExchangeClient', () => { 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: 1234 }, + { timeoutMs: timeoutMs }, ); expect(client).toBeDefined(); expect(client['apiKey']).toBe(apiKey); expect(client['secretKey']).toBe(secretKey); - expect(client['timeoutMs']).toBe(1234); + expect(client['timeoutMs']).toBe(timeoutMs); }); }); @@ -38,84 +47,90 @@ describe('MexcExchangeClient', () => { 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 query = 'timestamp=123&recvWindow=5000'; const signature = client['signQuery'](query); - expect(typeof signature).toBe('string'); - expect(signature.length).toBeGreaterThan(0); + 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 }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - } as Response); - + 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 }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); - + 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 }); - - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); - + 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 }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); - + 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); }); @@ -123,17 +138,13 @@ describe('MexcExchangeClient', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => generateMexcAccountBalance(['OTHER']), - } as Response); - + 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); }); @@ -141,18 +152,14 @@ describe('MexcExchangeClient', () => { 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]); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => balanceFixture, - } as Response); - + 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), @@ -163,16 +170,16 @@ describe('MexcExchangeClient', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); - + 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 index dabc6b2ece..e92e4e9f01 100644 --- 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 @@ -11,7 +11,7 @@ import type { } from './types'; import { fetchWithHandling } from './utils'; -const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; +export const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; export class MexcExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'mexc'; @@ -42,46 +42,31 @@ export class MexcExchangeClient implements ExchangeClient { async checkRequiredAccess(): Promise { const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = this.signQuery(query); + const { query, signature } = this.getSignedQuery(); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; const res = await fetchWithHandling( - this.id, url, { 'X-MEXC-APIKEY': this.apiKey }, this.logger, this.timeoutMs, ); if (res.ok) return true; - this.logger.debug('MEXC access check failed', { - status: res.status, - statusText: res.statusText, - }); return false; } async getAccountBalance(asset: string): Promise { const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = this.signQuery(query); + const { query, signature } = this.getSignedQuery(); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; const res = await fetchWithHandling( - this.id, url, { 'X-MEXC-APIKEY': this.apiKey }, this.logger, this.timeoutMs, ); if (!res.ok) { - this.logger.warn('MEXC balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); return 0; } const data = (await res.json()) as { @@ -95,4 +80,14 @@ export class MexcExchangeClient implements ExchangeClient { (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/utils.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts index ab4d3631ec..8a2fd42fdc 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts @@ -1,10 +1,9 @@ -import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import { DEFAULT_TIMEOUT_MS } from '@/common/constants'; import Logger from '@/logger'; import { ExchangeApiClientError } from './errors'; export async function fetchWithHandling( - id: SupportedExchange, url: string, headers: HeadersInit, logger: typeof Logger, @@ -18,8 +17,9 @@ export async function fetchWithHandling( }); return res; } catch (error) { - const message: string = `Failed to fetch ${id.toUpperCase()}`; + const message: string = `Failed to make request for exchange`; logger.error(message, { + url, error, }); throw new ExchangeApiClientError(message);